diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3c28346 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,161 @@ + +# GE Home Appliances (SmartHQ) Changelog + +## 2025.5.0 + +- Bugfix: Fixed helper deprecations +- Feature: Added boost/active states for water heaters +- Change: Improved documentation around terms of acceptance + +## 2025.2.1 + +- Bugfix: Fixed #339 + +## 2025.2.0 + +- Breaking: Changed dishwasher pods to number +- Breaking: Removed outdated laundry status sensor +- Feature: Added under counter ice maker controls and sensors +- Feature: Changed versioning scheme +- Bugfix: Updated SDK to fix broken types + +## 0.6.15 + +- Feature: Improved Support for Laundry +- Breaking: Some enums changed names/values and may need updates to client code +- Bugfix: More deprecation fixes + +## 0.6.14 + +- Bugfix: Error checking socket status [#304] +- Bugfix: Error with setup [#301] +- Bugfix: Logger deprecations + +## 0.6.13 + +- Bugfix: Deprecations [#290] [#297] + +## 0.6.12 + +- Bugfix: Deprecations [#271] + +## 0.6.11 + +- Bugfix: Fixed convertable drawer issue [#243] +- Bugfix: Updated app types to include electric cooktops [#252] +- Bugfix: Updated clientsession to remove deprecation [#253] +- Bugfix: Fixed error strings +- Bugfix: Updated climate support for new flags introduced in 2024.2.0 + +## 0.6.10 + +- Bugfix: Removed additional deprecated constants [#229] +- Bugfix: Fixed issue with climate entities [#228] + +## 0.6.9 + +- Added additional fridge controls [#200] +- Bugfix: Additional auth stability improvements [#215, #211] +- Bugfix: Removed deprecated constants [#218] + +## 0.6.8 + +- Added Dehumidifier [#114] +- Added oven drawer sensors +- Added oven current state sensors [#175] +- Added descriptors to manifest [#181] +- Bugfix: Fixed issue with oven lights [#174] +- Bugfix: Fixed issues with dual dishwasher [#161] +- Bugfix: Fixed disconnection issue [#169] + + +## 0.6.7 + +- Bugfix: fixed issues with dishwasher [#155] +- Added OIM descaling sensor [#154] + +## 0.6.6 + +- Bugfix: Fixed issue with region setting (EU accounts) [#130] +- Updated the temperature conversion (@partsdotpdf) +- Updated configuration documentation +- Modified dishwasher to include new functionality (@NickWaterton) +- Bugfix: Fixed oven typo (@jdc0730) [#149] +- Bugfix: UoM updates (@morlince) [#138] +- Updated light control (@tcgoetz) [#144] +- Dependency version bumps + +## 0.6.5 + +- Added beverage cooler support (@kksligh) +- Added dual dishwasher support (@jkili) +- Added initial espresso maker support (@datagen24) +- Added whole home water heater support (@seantibor) + +## 0.6.3 + +- Updated detection of invalid serial numbers (#89) +- Updated implementation of number entities to fix deprecation warnings (#85) + +## 0.6.2 + +- Fixed issue with water heater naming when no serial is present +- Initial support for built-in air conditioners (@DaveZheng) + +## 0.6.1 + +- Fixed issue with water filter life sensor (@rgabrielson11) + +## 0.6.0 + +- Requires HA 2021.12.x or later +- Enabled authentication to both US and EU regions +- Changed the sensors to use native value/uom +- Changed the temperatures to always be natively fahrenheit (API appears to always use this system) (@vignatyuk) +- Initial support for Microwaves (@mbcomer, @mnestor) +- Initial support for Water Softeners (@npentell, @drjeff) +- Initial support for Opal Ice Makers (@mbcomer, @knobunc) +- Initial support for Coffee Makers (@alexanv1) +- Updated deprecated icons (@mjmeli, @schmittx) + +## 0.5.0 + +- Initial support for oven hoods (@digitalbites) +- Added extended mode support for ovens +- Added logic to prevent multiple configurations of the same GE account +- Fixed device info when serial not present (@Xe138) +- Fixed issue with ovens when raw temperature not available (@chadohalloran) +- Fixed issue where Split A/C temperature sensors report UOM incorrectly (@RobertusIT) +- Added convertable drawer mode, proximity light, and interior lights to fridge (@groto27, @elwing00) +## 0.4.3 + +- Enabled support for appliances without serial numbers +- Added support for Split A/C units (@RobertusIT) +- Added support for Window A/C units (@mbrentrowe, @swcrawford1) +- Added support for Portable A/C units (@luddystefenson) +- Fixed multiple binary sensors (bad conversion from enum) (@steveredden) +- Fixed delay time interpretation for laundry (@steveredden, @sweichbr) +- Fixed startup issue when encountering an unknown unit type(@chansearrington, @opie546) +- Fixed interpretation of A/C demand response power (@garulf) +- Fixed issues with updating disabled entities (@willhayslett) +- Advantium fixes (@willhayslett) + +## 0.4.1 + +- Fixed an issue with dryer entities causing an error in HA (@steveredden) + +## 0.4.0 + +- Implemented Laundry Support (@warrenrees, @ssindsd) +- Implemented Water Filter Support (@bendavis, @tumtumsback, @rgabrielson11) +- Implemented Initial Advantium Support (@ssinsd) +- Bug fixes for ovens (@TKpizza) +- Additional authentication error handling (@rgabrielson11) +- Additional dishwasher functionality (@ssinsd) +- Introduced new select entity (@bendavis) +- Miscellaneous entity bug fixes/refinements +- Integrated new version of SDK + +## 0.3.12 + +- Initial tracked version \ No newline at end of file diff --git a/LICENSE b/LICENSE index 2f1db63..1637db2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 Andrew Marks +Copyright (c) 2021 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 79ceb95..9ee2bc6 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,91 @@ -# Custom Components for Home Assistant - -## `ge_kitchen` -Integration for GE WiFi-enabled kitchen appliances. So far, I've only done fridges and ovens (because that's what I -have), but I hope to to dishwashers next. Because HA doesn't have Fridge or Oven platforms, both fridges and ovens are -primarily represented as water heater entities, which works surprisingly well. If anybody who has other GE appliances -sees this and wants to pitch in, please shoot me a message or make a PR. - -Entities card: - -![Entities](https://raw.githubusercontent.com/ajmarks/ha_components/master/img/appliance_entities.png) - -Fridge Controls: - -![Fridge controls](https://raw.githubusercontent.com/ajmarks/ha_components/master/img/fridge_control.png) - -Oven Controls: - -![Fridge controls](https://raw.githubusercontent.com/ajmarks/ha_components/master/img/oven_controls.png) - -## What happened to `shark_iq`? - -It's part of Home Assistant as of [0.115](https://www.home-assistant.io/blog/2020/09/17/release-115/)! \ No newline at end of file +# GE Home Appliances (SmartHQ) + +[![GitHub Release][releases-shield]][releases] +[![GitHub Activity][commits-shield]][commits] +[![License][license-shield]](LICENSE) +[![hacs][hacsbadge]][hacs] + +Integration for GE WiFi-enabled appliances into Home Assistant. This integration currently supports the following devices: + +- Fridge +- Oven +- Dishwasher / F&P Dual Dishwasher +- Laundry (Washer/Dryer) +- Whole Home Water Filter +- Whole Home Water Softener +- Whole Home Water Heater +- A/C (Portable, Split, Window, Built-In) +- Range Hood +- Advantium +- Microwave +- Opal Ice Maker +- Coffee Maker / Espresso Maker +- Beverage Center + +**Forked from Andrew Mark's [repository](https://github.com/ajmarks/ha_components).** +## Updates + +Unfortunately, I'm pretty much at the end of what I can do without assistance from others with these devices that can help provide logs. I'll do what I can to make updates if there's something broken, but I am not really able to add new functionality if I can't get a little help to do so. + +## Home Assistant UI Examples +Entities card: + +![Entities](https://raw.githubusercontent.com/simbaja/ha_components/master/img/appliance_entities.png) + +Fridge Controls: + +![Fridge controls](https://raw.githubusercontent.com/simbaja/ha_components/master/img/fridge_control.png) + +Oven Controls: + +![Fridge controls](https://raw.githubusercontent.com/simbaja/ha_components/master/img/oven_controls.png) + +A/C Controls: + +![A/C controls](https://raw.githubusercontent.com/simbaja/ha_components/master/img/ac_controls.png) + +## Installation (Manual) + +1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). +2. If you do not have a `custom_components` directory (folder) there, you need to create it. +3. In the `custom_components` directory (folder) create a new folder called `ge_home`. +4. Download _all_ the files from the `custom_components/ge_home/` directory (folder) in this repository. +5. Place the files you downloaded in the new directory (folder) you created. +6. Restart Home Assistant +7. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "GE Home" + +## Installation (HACS) + +Please follow directions [here](https://hacs.xyz/docs/faq/custom_repositories/), and use https://github.com/simbaja/ha_gehome as the repository URL. + +## Configuration + +Configuration is done via the HA user interface. You need to have your device registered with the [SmartHQ](https://www.geappliances.com/connect) website. + +Once the HACS Integration of GE Home is completed: + +1. Navigate to Settings --> Devices & Services +2. Click **Add Integration** blue button on the bottom-right of the page +3. Locate the **GE Home (SmartHQ)** "Brand" (Integration) +4. Open a new browser tab and navigate to where you can verify your username/password (helpful) but more importantly Accept the TermsOfUseAgreement (required!) +5. Click on the integration, and you will be prompted to enter a Username, Password and Location (US or EU) +6. Enter the email address you used to register/connect your device as the Username +7. Same with the password +8. Select the region you registered your device in (US or EU). +9. Once you submit, the integration will log in and get all your connected devices. +10. You can define in which area you device is, then click **Finish** +11. Your sensors should appear as **sensor._** + ie: sensor.fs12345678_dishwasher_cycle_name + +## Change Log + +Please click [here](CHANGELOG.md) for change information. + +[commits-shield]: https://img.shields.io/github/commit-activity/y/simbaja/ha_gehome.svg?style=for-the-badge +[commits]: https://github.com/simbaja/ha_gehome/commits/master +[hacs]: https://github.com/custom-components/hacs +[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge +[license-shield]: https://img.shields.io/github/license/simbaja/ha_gehome.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/badge/maintainer-Jack%20Simbach%20%40simbaja-blue.svg?style=for-the-badge +[releases-shield]: https://img.shields.io/github/release/simbaja/ha_gehome.svg?style=for-the-badge +[releases]: https://github.com/simbaja/ha_gehome/releases diff --git a/custom_components/ge_home/__init__.py b/custom_components/ge_home/__init__.py new file mode 100644 index 0000000..f5337e8 --- /dev/null +++ b/custom_components/ge_home/__init__.py @@ -0,0 +1,81 @@ +"""The ge_home integration.""" + +import logging +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_REGION +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from .const import DOMAIN +from .exceptions import HaAuthError, HaCannotConnect +from .update_coordinator import GeHomeUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass: HomeAssistant, config: dict): + return True + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version == 1: + + new = {**config_entry.data} + new[CONF_REGION] = "US" + + config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry, data=new) + + _LOGGER.info("Migration to version %s successful", config_entry.version) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up ge_home from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + #try to get existing coordinator + existing: GeHomeUpdateCoordinator = dict.get(hass.data[DOMAIN],entry.entry_id) + + coordinator = GeHomeUpdateCoordinator(hass, entry) + hass.data[DOMAIN][entry.entry_id] = coordinator + + # try to unload the existing coordinator + try: + if existing: + await coordinator.async_reset() + except: + _LOGGER.warning("Could not reset existing coordinator.") + + try: + if not await coordinator.async_setup(): + return False + except HaCannotConnect: + raise ConfigEntryNotReady("Could not connect to SmartHQ") + except HaAuthError: + raise ConfigEntryAuthFailed("Could not authenticate to SmartHQ") + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, coordinator.shutdown) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + coordinator: GeHomeUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + ok = await coordinator.async_reset() + if ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return ok + + +async def async_update_options(hass, config_entry): + """Update options.""" + await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/custom_components/ge_home/binary_sensor.py b/custom_components/ge_home/binary_sensor.py new file mode 100644 index 0000000..0a35ef5 --- /dev/null +++ b/custom_components/ge_home/binary_sensor.py @@ -0,0 +1,47 @@ +"""GE Home Sensor Entities""" +import logging +from typing import Callable + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers import entity_registry as er + +from .const import DOMAIN +from .devices import ApplianceApi +from .entities import GeErdBinarySensor +from .update_coordinator import GeHomeUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable): + """GE Home binary sensors.""" + + _LOGGER.debug('Adding GE Binary Sensor Entities') + coordinator: GeHomeUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + registry = er.async_get(hass) + + @callback + def async_devices_discovered(apis: list[ApplianceApi]): + _LOGGER.debug(f'Found {len(apis):d} appliance APIs') + + entities = [ + entity + for api in apis + for entity in api.entities + if isinstance(entity, GeErdBinarySensor) and not isinstance(entity, SwitchEntity) + if not registry.async_is_registered(entity.entity_id) + ] + _LOGGER.debug(f'Found {len(entities):d} unregistered binary sensors') + async_add_entities(entities) + + #if we're already initialized at this point, call device + #discovery directly, otherwise add a callback based on the + #ready signal + if coordinator.initialized: + async_devices_discovered(coordinator.appliance_apis.values()) + else: + # add the ready signal and register the remove callback + coordinator.add_signal_remove_callback( + async_dispatcher_connect(hass, coordinator.signal_ready, async_devices_discovered)) diff --git a/custom_components/ge_home/button.py b/custom_components/ge_home/button.py new file mode 100644 index 0000000..748ee6b --- /dev/null +++ b/custom_components/ge_home/button.py @@ -0,0 +1,45 @@ +"""GE Home Button Entities""" +import logging +from typing import Callable + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers import entity_registry as er + +from .const import DOMAIN +from .devices import ApplianceApi +from .entities import GeErdButton +from .update_coordinator import GeHomeUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable): + """GE Home buttons.""" + + _LOGGER.debug('Adding GE Button Entities') + coordinator: GeHomeUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + registry = er.async_get(hass) + + @callback + def async_devices_discovered(apis: list[ApplianceApi]): + _LOGGER.debug(f'Found {len(apis):d} appliance APIs') + entities = [ + entity + for api in apis + for entity in api.entities + if isinstance(entity, GeErdButton) + if not registry.async_is_registered(entity.entity_id) + ] + _LOGGER.debug(f'Found {len(entities):d} unregistered buttons ') + async_add_entities(entities) + + #if we're already initialized at this point, call device + #discovery directly, otherwise add a callback based on the + #ready signal + if coordinator.initialized: + async_devices_discovered(coordinator.appliance_apis.values()) + else: + # add the ready signal and register the remove callback + coordinator.add_signal_remove_callback( + async_dispatcher_connect(hass, coordinator.signal_ready, async_devices_discovered)) diff --git a/custom_components/ge_home/climate.py b/custom_components/ge_home/climate.py new file mode 100644 index 0000000..4512b61 --- /dev/null +++ b/custom_components/ge_home/climate.py @@ -0,0 +1,47 @@ +"""GE Home Climate Entities""" +import logging +from typing import Callable + +from homeassistant.components.climate import ClimateEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers import entity_registry as er + +from .entities import GeClimate +from .const import DOMAIN +from .devices import ApplianceApi +from .update_coordinator import GeHomeUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable): + """GE Climate Devices.""" + + _LOGGER.debug('Adding GE Climate Entities') + coordinator: GeHomeUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + registry = er.async_get(hass) + + @callback + def async_devices_discovered(apis: list[ApplianceApi]): + _LOGGER.debug(f'Found {len(apis):d} appliance APIs') + + entities = [ + entity + for api in apis + for entity in api.entities + if isinstance(entity, GeClimate) + if not registry.async_is_registered(entity.entity_id) + ] + _LOGGER.debug(f'Found {len(entities):d} unregistered climate entities') + async_add_entities(entities) + + #if we're already initialized at this point, call device + #discovery directly, otherwise add a callback based on the + #ready signal + if coordinator.initialized: + async_devices_discovered(coordinator.appliance_apis.values()) + else: + # add the ready signal and register the remove callback + coordinator.add_signal_remove_callback( + async_dispatcher_connect(hass, coordinator.signal_ready, async_devices_discovered)) diff --git a/custom_components/ge_home/config_flow.py b/custom_components/ge_home/config_flow.py new file mode 100644 index 0000000..a8070a1 --- /dev/null +++ b/custom_components/ge_home/config_flow.py @@ -0,0 +1,129 @@ +"""Config flow for GE Home integration.""" + +import logging +from typing import Dict, Optional + +import aiohttp +import asyncio +import async_timeout + +from gehomesdk import ( + GeAuthFailedError, + GeNotAuthenticatedError, + GeGeneralServerError, + async_get_oauth2_token, + LOGIN_REGIONS +) +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_REGION +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN # pylint:disable=unused-import +from .exceptions import HaAuthError, HaCannotConnect, HaAlreadyConfigured + +_LOGGER = logging.getLogger(__name__) + +GEHOME_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_REGION): vol.In(LOGIN_REGIONS.keys()) + } +) + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect.""" + + session = async_get_clientsession(hass) + + # noinspection PyBroadException + try: + async with async_timeout.timeout(10): + _ = await async_get_oauth2_token(session, data[CONF_USERNAME], data[CONF_PASSWORD], data[CONF_REGION]) + except (asyncio.TimeoutError, aiohttp.ClientError): + raise HaCannotConnect('Connection failure') + except (GeAuthFailedError, GeNotAuthenticatedError): + raise HaAuthError('Authentication failure') + except GeGeneralServerError: + raise HaCannotConnect('Cannot connect (server error)') + except Exception as exc: + _LOGGER.exception("Unknown connection failure", exc_info=exc) + raise HaCannotConnect('Unknown connection failure') + + # Return info that you want to store in the config entry. + return {"title": f"{data[CONF_USERNAME]:s}"} + +class GeHomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for GE Home.""" + + VERSION = 2 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH + + async def _async_validate_input(self, user_input): + """Validate form input.""" + errors = {} + info = None + + if user_input is not None: + # noinspection PyBroadException + try: + info = await validate_input(self.hass, user_input) + except HaCannotConnect: + errors["base"] = "cannot_connect" + except HaAuthError: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return info, errors + + def _ensure_not_configured(self, username: str): + """Ensure that we haven't configured this account""" + existing_accounts = { + entry.data[CONF_USERNAME] for entry in self._async_current_entries() + } + _LOGGER.debug(f"Existing accounts: {existing_accounts}") + if username in existing_accounts: + raise HaAlreadyConfigured + + async def async_step_user(self, user_input: Optional[Dict] = None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + self._ensure_not_configured(user_input[CONF_USERNAME]) + info, errors = await self._async_validate_input(user_input) + if info: + return self.async_create_entry(title=info["title"], data=user_input) + except HaAlreadyConfigured: + return self.async_abort(reason="already_configured_account") + + + return self.async_show_form( + step_id="user", data_schema=GEHOME_SCHEMA, errors=errors + ) + + async def async_step_reauth(self, user_input: Optional[dict] = None): + """Handle re-auth if login is invalid.""" + errors = {} + + if user_input is not None: + _, errors = await self._async_validate_input(user_input) + + if not errors: + for entry in self._async_current_entries(): + if entry.unique_id == self.unique_id: + self.hass.config_entries.async_update_entry( + entry, data=user_input + ) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + + if errors["base"] != "invalid_auth": + return self.async_abort(reason=errors["base"]) + + return self.async_show_form( + step_id="reauth", data_schema=GEHOME_SCHEMA, errors=errors, + ) diff --git a/custom_components/ge_home/const.py b/custom_components/ge_home/const.py new file mode 100644 index 0000000..76cef76 --- /dev/null +++ b/custom_components/ge_home/const.py @@ -0,0 +1,15 @@ +"""Constants for the gehome integration.""" + +DOMAIN = "ge_home" + +EVENT_ALL_APPLIANCES_READY = 'all_appliances_ready' + +UPDATE_INTERVAL = 30 +ASYNC_TIMEOUT = 30 +MIN_RETRY_DELAY = 15 +MAX_RETRY_DELAY = 1800 +RETRY_OFFLINE_COUNT = 5 + +SERVICE_SET_TIMER = "set_timer" +SERVICE_CLEAR_TIMER = "clear_timer" +SERVICE_SET_INT_VALUE = "set_int_value" diff --git a/custom_components/ge_home/devices/__init__.py b/custom_components/ge_home/devices/__init__.py new file mode 100644 index 0000000..297da34 --- /dev/null +++ b/custom_components/ge_home/devices/__init__.py @@ -0,0 +1,66 @@ +import logging +from typing import Type + +from gehomesdk.erd import ErdApplianceType + +from .base import ApplianceApi +from .oven import OvenApi +from .cooktop import CooktopApi +from .fridge import FridgeApi +from .dishwasher import DishwasherApi +from .washer import WasherApi +from .dryer import DryerApi +from .washer_dryer import WasherDryerApi +from .water_filter import WaterFilterApi +from .advantium import AdvantiumApi +from .wac import WacApi +from .sac import SacApi +from .pac import PacApi +from .biac import BiacApi +from .hood import HoodApi +from .microwave import MicrowaveApi +from .water_softener import WaterSoftenerApi +from .water_heater import WaterHeaterApi +from .oim import OimApi +from .ucim import UcimApi +from .coffee_maker import CcmApi +from .dual_dishwasher import DualDishwasherApi +from .espresso_maker import EspressoMakerApi +from .dehumidifier import DehumidifierApi + +_LOGGER = logging.getLogger(__name__) + + +def get_appliance_api_type(appliance_type: ErdApplianceType) -> Type: + """Get the appropriate appliance type""" + _LOGGER.debug(f"Found device type: {appliance_type}") + known_types = { + ErdApplianceType.OVEN: OvenApi, + ErdApplianceType.COOKTOP: CooktopApi, + ErdApplianceType.ELECTRIC_COOKTOP: CooktopApi, + ErdApplianceType.FRIDGE: FridgeApi, + ErdApplianceType.BEVERAGE_CENTER: FridgeApi, + ErdApplianceType.DISH_WASHER: DishwasherApi, + ErdApplianceType.DUAL_DISH_WASHER: DualDishwasherApi, + ErdApplianceType.WASHER: WasherApi, + ErdApplianceType.DRYER: DryerApi, + ErdApplianceType.COMBINATION_WASHER_DRYER: WasherDryerApi, + ErdApplianceType.POE_WATER_FILTER: WaterFilterApi, + ErdApplianceType.WATER_SOFTENER: WaterSoftenerApi, + ErdApplianceType.WATER_HEATER: WaterHeaterApi, + ErdApplianceType.ADVANTIUM: AdvantiumApi, + ErdApplianceType.AIR_CONDITIONER: WacApi, + ErdApplianceType.SPLIT_AIR_CONDITIONER: SacApi, + ErdApplianceType.PORTABLE_AIR_CONDITIONER: PacApi, + ErdApplianceType.BUILT_IN_AIR_CONDITIONER: BiacApi, + ErdApplianceType.HOOD: HoodApi, + ErdApplianceType.MICROWAVE: MicrowaveApi, + ErdApplianceType.OPAL_ICE_MAKER: OimApi, + ErdApplianceType.UNDER_COUNTER_ICE_MAKER: UcimApi, + ErdApplianceType.CAFE_COFFEE_MAKER: CcmApi, + ErdApplianceType.ESPRESSO_MAKER: EspressoMakerApi, + ErdApplianceType.DEHUMIDIFIER: DehumidifierApi + } + + # Get the appliance type + return known_types.get(appliance_type, ApplianceApi) diff --git a/custom_components/ge_home/devices/advantium.py b/custom_components/ge_home/devices/advantium.py new file mode 100644 index 0000000..c3baf36 --- /dev/null +++ b/custom_components/ge_home/devices/advantium.py @@ -0,0 +1,45 @@ +import logging +from typing import List + +from homeassistant.helpers.entity import Entity +from gehomesdk.erd import ErdCode, ErdApplianceType, ErdDataType + +from .base import ApplianceApi +from ..entities import GeAdvantium, GeErdSensor, GeErdBinarySensor, GeErdPropertySensor, GeErdPropertyBinarySensor, UPPER_OVEN + +_LOGGER = logging.getLogger(__name__) + +class AdvantiumApi(ApplianceApi): + """API class for Advantium objects""" + APPLIANCE_TYPE = ErdApplianceType.ADVANTIUM + + def get_all_entities(self) -> List[Entity]: + base_entities = super().get_all_entities() + + advantium_entities = [ + GeErdSensor(self, ErdCode.PERSONALITY), + GeErdBinarySensor(self, ErdCode.UPPER_OVEN_REMOTE_ENABLED, self._single_name(ErdCode.UPPER_OVEN_REMOTE_ENABLED)), + GeErdBinarySensor(self, ErdCode.MICROWAVE_REMOTE_ENABLE), + GeErdSensor(self, ErdCode.UPPER_OVEN_DISPLAY_TEMPERATURE, self._single_name(ErdCode.UPPER_OVEN_DISPLAY_TEMPERATURE)), + GeErdSensor(self, ErdCode.ADVANTIUM_KITCHEN_TIME_REMAINING), + GeErdSensor(self, ErdCode.ADVANTIUM_COOK_TIME_REMAINING), + GeAdvantium(self), + + #Cook Status + GeErdPropertySensor(self, ErdCode.ADVANTIUM_COOK_STATUS, "cook_mode"), + GeErdPropertySensor(self, ErdCode.ADVANTIUM_COOK_STATUS, "termination_reason", icon_override="mdi:information-outline"), + GeErdPropertySensor(self, ErdCode.ADVANTIUM_COOK_STATUS, "preheat_status", icon_override="mdi:fire"), + GeErdPropertySensor(self, ErdCode.ADVANTIUM_COOK_STATUS, "temperature", icon_override="mdi:thermometer", data_type_override=ErdDataType.INT), + GeErdPropertySensor(self, ErdCode.ADVANTIUM_COOK_STATUS, "power_level", icon_override="mdi:gauge", data_type_override=ErdDataType.INT), + GeErdPropertySensor(self, ErdCode.ADVANTIUM_COOK_STATUS, "warm_status", icon_override="mdi:radiator"), + GeErdPropertyBinarySensor(self, ErdCode.ADVANTIUM_COOK_STATUS, "door_status", device_class_override="door"), + GeErdPropertyBinarySensor(self, ErdCode.ADVANTIUM_COOK_STATUS, "sensing_active", icon_on_override="mdi:flash-auto", icon_off_override="mdi:flash-off"), + GeErdPropertyBinarySensor(self, ErdCode.ADVANTIUM_COOK_STATUS, "cooling_fan_status", icon_on_override="mdi:fan", icon_off_override="mdi:fan-off"), + GeErdPropertyBinarySensor(self, ErdCode.ADVANTIUM_COOK_STATUS, "oven_light_status", icon_on_override="mdi:lightbulb-on", icon_off_override="mdi:lightbulb-off"), + ] + entities = base_entities + advantium_entities + return entities + + def _single_name(self, erd_code: ErdCode): + return erd_code.name.replace(UPPER_OVEN+"_","").replace("_", " ").title() + diff --git a/custom_components/ge_home/devices/base.py b/custom_components/ge_home/devices/base.py new file mode 100644 index 0000000..179e3e9 --- /dev/null +++ b/custom_components/ge_home/devices/base.py @@ -0,0 +1,154 @@ +import asyncio +import logging +from typing import Dict, List, Optional + +from gehomesdk import GeAppliance +from gehomesdk.erd import ErdCode, ErdCodeType, ErdApplianceType + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from ..const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +class ApplianceApi: + """ + API class to represent a single physical device. + + Since a physical device can have many entities, we"ll pool common elements here + """ + APPLIANCE_TYPE = None # type: Optional[ErdApplianceType] + + def __init__(self, coordinator: DataUpdateCoordinator, appliance: GeAppliance): + if not appliance.initialized: + raise RuntimeError("Appliance not ready") + self._appliance = appliance + self._loop = appliance.client.loop + self._hass = coordinator.hass + self.coordinator = coordinator + self.initial_update = False + self._entities = {} # type: Optional[Dict[str, Entity]] + + @property + def hass(self) -> HomeAssistant: + return self._hass + + @property + def loop(self) -> Optional[asyncio.AbstractEventLoop]: + if self._loop is None: + self._loop = self._appliance.client.loop + return self._loop + + @property + def appliance(self) -> GeAppliance: + return self._appliance + + @appliance.setter + def appliance(self, value: GeAppliance): + self._appliance = value + + @property + def available(self) -> bool: + #Note - online will be there since we're using the GE coordinator + #Didn't want to deal with the circular references to get the type hints + #working. + return self.appliance.available and self.coordinator.online + + @property + def serial_number(self) -> str: + return self.appliance.get_erd_value(ErdCode.SERIAL_NUMBER) + + @property + def mac_addr(self) -> str: + return self.appliance.mac_addr + + @property + def serial_or_mac(self) -> str: + def is_zero(val: str) -> bool: + try: + intVal = int(val) + return intVal == 0 + except: + return False + + if (self.serial_number and not + self.serial_number.isspace() and not + is_zero(self.serial_number)): + return self.serial_number + return self.mac_addr + + @property + def model_number(self) -> str: + return self.appliance.get_erd_value(ErdCode.MODEL_NUMBER) + + @property + def sw_version(self) -> str: + appVer = self.try_get_erd_value(ErdCode.APPLIANCE_SW_VERSION) + wifiVer = self.try_get_erd_value(ErdCode.WIFI_MODULE_SW_VERSION) + + return 'Appliance=' + str(appVer or 'Unknown') + '/Wifi=' + str(wifiVer or 'Unknown') + + @property + def name(self) -> str: + appliance_type = self.appliance.appliance_type + if appliance_type is None or appliance_type == ErdApplianceType.UNKNOWN: + appliance_type = "Appliance" + else: + appliance_type = appliance_type.name.replace("_", " ").title() + return f"GE {appliance_type} {self.serial_or_mac}" + + @property + def device_info(self) -> Dict: + """Device info dictionary.""" + + return { + "identifiers": {(DOMAIN, self.serial_or_mac)}, + "name": self.name, + "manufacturer": "GE", + "model": self.model_number, + "sw_version": self.sw_version + } + + @property + def entities(self) -> List[Entity]: + return list(self._entities.values()) + + def get_all_entities(self) -> List[Entity]: + """Create Entities for this device.""" + return self.get_base_entities() + + def get_base_entities(self) -> List[Entity]: + """Create base entities (i.e. common between all appliances).""" + from ..entities import GeErdSensor, GeErdSwitch + entities = [ + GeErdSensor(self, ErdCode.CLOCK_TIME), + GeErdSwitch(self, ErdCode.SABBATH_MODE), + ] + return entities + + def build_entities_list(self) -> None: + """Build the entities list, adding anything new.""" + from ..entities import GeErdEntity, GeErdButton + entities = [ + e for e in self.get_all_entities() + if not isinstance(e, GeErdEntity) or isinstance(e, GeErdButton) or e.erd_code in self.appliance.known_properties + ] + + for entity in entities: + if entity.unique_id not in self._entities: + self._entities[entity.unique_id] = entity + + def try_get_erd_value(self, code: ErdCodeType): + try: + return self.appliance.get_erd_value(code) + except: + return None + + def has_erd_code(self, code: ErdCodeType): + try: + self.appliance.get_erd_value(code) + return True + except: + return False diff --git a/custom_components/ge_home/devices/biac.py b/custom_components/ge_home/devices/biac.py new file mode 100644 index 0000000..916b6cb --- /dev/null +++ b/custom_components/ge_home/devices/biac.py @@ -0,0 +1,35 @@ +import logging +from typing import List + +from homeassistant.helpers.entity import Entity +from gehomesdk.erd import ErdCode, ErdApplianceType + +from .base import ApplianceApi +from ..entities import GeSacClimate, GeErdSensor, GeErdSwitch, ErdOnOffBoolConverter, GeErdBinarySensor + + +_LOGGER = logging.getLogger(__name__) + + +class BiacApi(ApplianceApi): + """API class for Built-In AC objects""" + APPLIANCE_TYPE = ErdApplianceType.BUILT_IN_AIR_CONDITIONER + + def get_all_entities(self) -> List[Entity]: + base_entities = super().get_all_entities() + + sac_entities = [ + GeSacClimate(self), + GeErdSensor(self, ErdCode.AC_TARGET_TEMPERATURE), + GeErdSensor(self, ErdCode.AC_AMBIENT_TEMPERATURE), + GeErdSensor(self, ErdCode.AC_FAN_SETTING, icon_override="mdi:fan"), + GeErdSensor(self, ErdCode.AC_OPERATION_MODE), + GeErdSwitch(self, ErdCode.AC_POWER_STATUS, bool_converter=ErdOnOffBoolConverter(), icon_on_override="mdi:power-on", icon_off_override="mdi:power-off"), + GeErdBinarySensor(self, ErdCode.AC_FILTER_STATUS, device_class_override="problem"), + GeErdSensor(self, ErdCode.WAC_DEMAND_RESPONSE_STATE), + GeErdSensor(self, ErdCode.WAC_DEMAND_RESPONSE_POWER, uom_override="kW"), + ] + + entities = base_entities + sac_entities + return entities + diff --git a/custom_components/ge_home/devices/coffee_maker.py b/custom_components/ge_home/devices/coffee_maker.py new file mode 100644 index 0000000..d3f39c9 --- /dev/null +++ b/custom_components/ge_home/devices/coffee_maker.py @@ -0,0 +1,66 @@ +import logging +from typing import List + +from homeassistant.helpers.entity import Entity +from gehomesdk import ( + GeAppliance, + ErdCode, + ErdApplianceType, + ErdCcmBrewSettings +) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .base import ApplianceApi +from ..entities import ( + GeCcmPotNotPresentBinarySensor, + GeErdSensor, + GeErdBinarySensor, + GeErdButton, + GeCcmBrewStrengthSelect, + GeCcmBrewTemperatureNumber, + GeCcmBrewCupsNumber, + GeCcmBrewSettingsButton +) + +_LOGGER = logging.getLogger(__name__) + + +class CcmApi(ApplianceApi): + """API class for Cafe Coffee Maker objects""" + APPLIANCE_TYPE = ErdApplianceType.CAFE_COFFEE_MAKER + + def __init__(self, coordinator: DataUpdateCoordinator, appliance: GeAppliance): + super().__init__(coordinator, appliance) + + self._brew_strengh_entity = GeCcmBrewStrengthSelect(self) + self._brew_temperature_entity = GeCcmBrewTemperatureNumber(self) + self._brew_cups_entity = GeCcmBrewCupsNumber(self) + + def get_all_entities(self) -> List[Entity]: + base_entities = super().get_all_entities() + + ccm_entities = [ + GeErdBinarySensor(self, ErdCode.CCM_IS_BREWING), + GeErdBinarySensor(self, ErdCode.CCM_IS_DESCALING), + GeCcmBrewSettingsButton(self), + GeErdButton(self, ErdCode.CCM_CANCEL_DESCALING), + GeErdButton(self, ErdCode.CCM_START_DESCALING), + GeErdButton(self, ErdCode.CCM_CANCEL_BREWING), + self._brew_strengh_entity, + self._brew_temperature_entity, + self._brew_cups_entity, + GeErdSensor(self, ErdCode.CCM_CURRENT_WATER_TEMPERATURE), + GeErdBinarySensor(self, ErdCode.CCM_OUT_OF_WATER, device_class_override="problem"), + GeCcmPotNotPresentBinarySensor(self, ErdCode.CCM_POT_PRESENT, device_class_override="problem") + ] + + entities = base_entities + ccm_entities + return entities + + async def start_brewing(self) -> None: + """Aggregate brew settings and start brewing.""" + + new_mode = ErdCcmBrewSettings(self._brew_cups_entity.native_value, + self._brew_strengh_entity.brew_strength, + self._brew_temperature_entity.native_value) + await self.appliance.async_set_erd_value(ErdCode.CCM_BREW_SETTINGS, new_mode) \ No newline at end of file diff --git a/custom_components/ge_home/devices/cooktop.py b/custom_components/ge_home/devices/cooktop.py new file mode 100644 index 0000000..32fb597 --- /dev/null +++ b/custom_components/ge_home/devices/cooktop.py @@ -0,0 +1,62 @@ +import logging +from typing import List +from gehomesdk.erd.erd_data_type import ErdDataType + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.helpers.entity import Entity +from gehomesdk import ( + ErdCode, + ErdApplianceType, + ErdCooktopConfig, + CooktopStatus +) + +from .base import ApplianceApi +from ..entities import ( + GeErdBinarySensor, + GeErdPropertySensor, + GeErdPropertyBinarySensor +) + +_LOGGER = logging.getLogger(__name__) + +class CooktopApi(ApplianceApi): + """API class for cooktop objects""" + APPLIANCE_TYPE = ErdApplianceType.COOKTOP + + def get_all_entities(self) -> List[Entity]: + base_entities = super().get_all_entities() + + cooktop_config = ErdCooktopConfig.NONE + if self.has_erd_code(ErdCode.COOKTOP_CONFIG): + cooktop_config: ErdCooktopConfig = self.appliance.get_erd_value(ErdCode.COOKTOP_CONFIG) + + _LOGGER.debug(f"Cooktop Config: {cooktop_config}") + cooktop_entities = [] + + if cooktop_config == ErdCooktopConfig.PRESENT: + # attempt to get the cooktop status using legacy status + cooktop_status_erd = ErdCode.COOKTOP_STATUS + cooktop_status: CooktopStatus = self.try_get_erd_value(ErdCode.COOKTOP_STATUS) + + # if we didn't get it, try using the new version + if cooktop_status is None: + cooktop_status_erd = ErdCode.COOKTOP_STATUS_EXT + cooktop_status: CooktopStatus = self.try_get_erd_value(ErdCode.COOKTOP_STATUS_EXT) + + # if we got a status through either mechanism, we can add the entities + if cooktop_status is not None: + cooktop_entities.append(GeErdBinarySensor(self, cooktop_status_erd)) + + for (k, v) in cooktop_status.burners.items(): + if v.exists: + prop = self._camel_to_snake(k) + cooktop_entities.append(GeErdPropertyBinarySensor(self, cooktop_status_erd, prop+".on")) + cooktop_entities.append(GeErdPropertyBinarySensor(self, cooktop_status_erd, prop+".synchronized")) + if not v.on_off_only: + cooktop_entities.append(GeErdPropertySensor(self, cooktop_status_erd, prop+".power_pct", icon_override="mdi:fire", device_class_override=SensorDeviceClass.POWER_FACTOR, data_type_override=ErdDataType.INT)) + + return base_entities + cooktop_entities + + def _camel_to_snake(self, s): + return ''.join(['_'+c.lower() if c.isupper() else c for c in s]).lstrip('_') diff --git a/custom_components/ge_home/devices/dehumidifier.py b/custom_components/ge_home/devices/dehumidifier.py new file mode 100644 index 0000000..fa0d8cc --- /dev/null +++ b/custom_components/ge_home/devices/dehumidifier.py @@ -0,0 +1,44 @@ +import logging +from typing import List + +from homeassistant.helpers.entity import Entity +from gehomesdk import ( + ErdCode, + ErdApplianceType, + ErdOnOff +) + +from .base import ApplianceApi +from ..entities import ( + GeErdSensor, + GeErdSelect, + GeErdPropertySensor, + GeErdSwitch, + ErdOnOffBoolConverter, + GeDehumidifierFanSpeedSensor, + GeDehumidifier +) + +_LOGGER = logging.getLogger(__name__) + + +class DehumidifierApi(ApplianceApi): + """API class for Dehumidifier objects""" + APPLIANCE_TYPE = ErdApplianceType.DEHUMIDIFIER + + def get_all_entities(self) -> List[Entity]: + base_entities = super().get_all_entities() + + dhum_entities = [ + GeErdSwitch(self, ErdCode.AC_POWER_STATUS, bool_converter=ErdOnOffBoolConverter(), icon_on_override="mdi:power-on", icon_off_override="mdi:power-off"), + GeDehumidifierFanSpeedSensor(self, ErdCode.AC_FAN_SETTING, icon_override="mdi:fan"), + GeErdSensor(self, ErdCode.DHUM_CURRENT_HUMIDITY), + GeErdSensor(self, ErdCode.DHUM_TARGET_HUMIDITY), + GeErdPropertySensor(self, ErdCode.DHUM_MAINTENANCE, "empty_bucket", device_class_override="problem"), + GeErdPropertySensor(self, ErdCode.DHUM_MAINTENANCE, "clean_filter", device_class_override="problem"), + GeDehumidifier(self) + ] + + entities = base_entities + dhum_entities + return entities + diff --git a/custom_components/ge_home/devices/dishwasher.py b/custom_components/ge_home/devices/dishwasher.py new file mode 100644 index 0000000..8c4e91c --- /dev/null +++ b/custom_components/ge_home/devices/dishwasher.py @@ -0,0 +1,55 @@ +import logging +from typing import List + +from homeassistant.helpers.entity import Entity +from gehomesdk.erd import ErdCode, ErdApplianceType + +from .base import ApplianceApi +from ..entities import GeErdSensor, GeErdBinarySensor, GeErdPropertySensor, GeErdNumber + +_LOGGER = logging.getLogger(__name__) + + +class DishwasherApi(ApplianceApi): + """API class for dishwasher objects""" + APPLIANCE_TYPE = ErdApplianceType.DISH_WASHER + + def get_all_entities(self) -> List[Entity]: + base_entities = super().get_all_entities() + + dishwasher_entities = [ + #GeDishwasherControlLockedSwitch(self, ErdCode.USER_INTERFACE_LOCKED), + GeErdSensor(self, ErdCode.DISHWASHER_CYCLE_NAME), + GeErdSensor(self, ErdCode.DISHWASHER_CYCLE_STATE, icon_override="mdi:state-machine"), + GeErdSensor(self, ErdCode.DISHWASHER_OPERATING_MODE), +# GeErdSensor(self, ErdCode.DISHWASHER_PODS_REMAINING_VALUE, uom_override="pods"), + GeErdNumber(self, ErdCode.DISHWASHER_PODS_REMAINING_VALUE, uom_override="pods", min_value=0, max_value=255), + GeErdPropertySensor(self, ErdCode.DISHWASHER_REMINDERS, "add_rinse_aid", icon_override="mdi:shimmer"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_REMINDERS, "clean_filter", icon_override="mdi:dishwasher-alert"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_REMINDERS, "sanitized", icon_override="mdi:silverware-clean"), + GeErdSensor(self, ErdCode.DISHWASHER_TIME_REMAINING), + GeErdBinarySensor(self, ErdCode.DISHWASHER_DOOR_STATUS), + GeErdBinarySensor(self, ErdCode.DISHWASHER_IS_CLEAN), + GeErdBinarySensor(self, ErdCode.DISHWASHER_REMOTE_START_ENABLE), + + #User Setttings + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "mute", icon_override="mdi:volume-mute"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "lock_control", icon_override="mdi:lock"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "sabbath", icon_override="mdi:star-david"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "cycle_mode", icon_override="mdi:state-machine"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "presoak", icon_override="mdi:water"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "bottle_jet", icon_override="mdi:bottle-tonic-outline"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "wash_temp", icon_override="mdi:coolant-temperature"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "rinse_aid", icon_override="mdi:shimmer"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "dry_option", icon_override="mdi:fan"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "wash_zone", icon_override="mdi:dock-top"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "delay_hours", icon_override="mdi:clock-fast"), + + #Cycle Counts + GeErdPropertySensor(self, ErdCode.DISHWASHER_CYCLE_COUNTS, "started", icon_override="mdi:counter"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_CYCLE_COUNTS, "completed", icon_override="mdi:counter"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_CYCLE_COUNTS, "reset", icon_override="mdi:counter") + ] + entities = base_entities + dishwasher_entities + return entities + diff --git a/custom_components/ge_home/devices/dryer.py b/custom_components/ge_home/devices/dryer.py new file mode 100644 index 0000000..cc0110d --- /dev/null +++ b/custom_components/ge_home/devices/dryer.py @@ -0,0 +1,66 @@ +import logging +from typing import List + +from homeassistant.helpers.entity import Entity +from gehomesdk import ErdCode, ErdApplianceType + +from .base import ApplianceApi +from ..entities import GeErdSensor, GeErdBinarySensor + +_LOGGER = logging.getLogger(__name__) + +class DryerApi(ApplianceApi): + """API class for dryer objects""" + APPLIANCE_TYPE = ErdApplianceType.DRYER + + def get_all_entities(self) -> List[Entity]: + base_entities = super().get_all_entities() + + common_entities = [ + GeErdSensor(self, ErdCode.LAUNDRY_MACHINE_STATE, icon_override="mdi:tumble-dryer"), + GeErdSensor(self, ErdCode.LAUNDRY_CYCLE, icon_override="mdi:state-machine"), + GeErdSensor(self, ErdCode.LAUNDRY_SUB_CYCLE, icon_override="mdi:state-machine"), + GeErdBinarySensor(self, ErdCode.LAUNDRY_END_OF_CYCLE, icon_on_override="mdi:tumble-dryer", icon_off_override="mdi:tumble-dryer"), + GeErdSensor(self, ErdCode.LAUNDRY_TIME_REMAINING), + GeErdSensor(self, ErdCode.LAUNDRY_DELAY_TIME_REMAINING), + GeErdBinarySensor(self, ErdCode.LAUNDRY_DOOR), + GeErdBinarySensor(self, ErdCode.LAUNDRY_REMOTE_STATUS, icon_on_override="mdi:tumble-dryer", icon_off_override="mdi:tumble-dryer"), + GeErdBinarySensor(self, ErdCode.LAUNDRY_DRYER_BLOCKED_VENT_FAULT, icon_on_override="mid:alert-circle", icon_off_override="mdi:alert-circle"), + ] + + dryer_entities = self.get_dryer_entities() + + entities = base_entities + common_entities + dryer_entities + return entities + + def get_dryer_entities(self): + #Not all options appear to exist on every dryer... we'll look for the presence of + #a code to figure out which sensors are applicable beyond the common ones. + dryer_entities = [ + ] + + if self.has_erd_code(ErdCode.LAUNDRY_DRYER_DRYNESS_LEVEL): + dryer_entities.extend([GeErdSensor(self, ErdCode.LAUNDRY_DRYER_DRYNESS_LEVEL)]) + if self.has_erd_code(ErdCode.LAUNDRY_DRYER_DRYNESSNEW_LEVEL): + dryer_entities.extend([GeErdSensor(self, ErdCode.LAUNDRY_DRYER_DRYNESSNEW_LEVEL)]) + if self.has_erd_code(ErdCode.LAUNDRY_DRYER_TEMPERATURE_OPTION): + dryer_entities.extend([GeErdSensor(self, ErdCode.LAUNDRY_DRYER_TEMPERATURE_OPTION)]) + if self.has_erd_code(ErdCode.LAUNDRY_DRYER_TEMPERATURENEW_OPTION): + dryer_entities.extend([GeErdSensor(self, ErdCode.LAUNDRY_DRYER_TEMPERATURENEW_OPTION)]) + if self.has_erd_code(ErdCode.LAUNDRY_DRYER_TUMBLE_STATUS): + dryer_entities.extend([GeErdSensor(self, ErdCode.LAUNDRY_DRYER_TUMBLE_STATUS)]) + if self.has_erd_code(ErdCode.LAUNDRY_DRYER_EXTENDED_TUMBLE_OPTION_SELECTION): + dryer_entities.extend([GeErdSensor(self, ErdCode.LAUNDRY_DRYER_EXTENDED_TUMBLE_OPTION_SELECTION)]) + if self.has_erd_code(ErdCode.LAUNDRY_DRYER_WASHERLINK_STATUS): + dryer_entities.extend([GeErdBinarySensor(self, ErdCode.LAUNDRY_DRYER_WASHERLINK_STATUS)]) + if self.has_erd_code(ErdCode.LAUNDRY_DRYER_LEVEL_SENSOR_DISABLED): + dryer_entities.extend([GeErdBinarySensor(self, ErdCode.LAUNDRY_DRYER_LEVEL_SENSOR_DISABLED)]) + if self.has_erd_code(ErdCode.LAUNDRY_DRYER_SHEET_USAGE_CONFIGURATION): + dryer_entities.extend([GeErdSensor(self, ErdCode.LAUNDRY_DRYER_SHEET_USAGE_CONFIGURATION)]) + if self.has_erd_code(ErdCode.LAUNDRY_DRYER_SHEET_INVENTORY): + dryer_entities.extend([GeErdSensor(self, ErdCode.LAUNDRY_DRYER_SHEET_INVENTORY, icon_override="mdi:tray-full", uom_override="sheets")]) + if self.has_erd_code(ErdCode.LAUNDRY_DRYER_ECODRY_OPTION_SELECTION): + dryer_entities.extend([GeErdSensor(self, ErdCode.LAUNDRY_DRYER_ECODRY_OPTION_SELECTION)]) + + return dryer_entities + diff --git a/custom_components/ge_home/devices/dual_dishwasher.py b/custom_components/ge_home/devices/dual_dishwasher.py new file mode 100644 index 0000000..158da3c --- /dev/null +++ b/custom_components/ge_home/devices/dual_dishwasher.py @@ -0,0 +1,71 @@ +import logging +from typing import List + +from homeassistant.helpers.entity import Entity +from gehomesdk.erd import ErdCode, ErdApplianceType + +from .base import ApplianceApi +from ..entities import GeErdSensor, GeErdBinarySensor, GeErdPropertySensor + +_LOGGER = logging.getLogger(__name__) + + +class DualDishwasherApi(ApplianceApi): + """API class for dual dishwasher objects""" + APPLIANCE_TYPE = ErdApplianceType.DISH_WASHER + + def get_all_entities(self) -> List[Entity]: + base_entities = super().get_all_entities() + + lower_entities = [ + GeErdSensor(self, ErdCode.DISHWASHER_CYCLE_STATE, erd_override="lower_cycle_state", icon_override="mdi:state-machine"), + GeErdSensor(self, ErdCode.DISHWASHER_TIME_REMAINING, erd_override="lower_time_remaining"), + GeErdBinarySensor(self, ErdCode.DISHWASHER_DOOR_STATUS, erd_override="lower_door_status"), + + #Reminders + GeErdPropertySensor(self, ErdCode.DISHWASHER_REMINDERS, "add_rinse_aid", erd_override="lower_reminder", icon_override="mdi:shimmer"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_REMINDERS, "clean_filter", erd_override="lower_reminder", icon_override="mdi:dishwasher-alert"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_REMINDERS, "sanitized", erd_override="lower_reminder", icon_override="mdi:silverware-clean"), + + #User Setttings + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "mute", erd_override="lower_setting", icon_override="mdi:volume-mute"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "lock_control", erd_override="lower_setting", icon_override="mdi:lock"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "sabbath", erd_override="lower_setting", icon_override="mdi:star-david"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "cycle_mode", erd_override="lower_setting", icon_override="mdi:state-machine"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "presoak", erd_override="lower_setting", icon_override="mdi:water"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "bottle_jet", erd_override="lower_setting", icon_override="mdi:bottle-tonic-outline"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "wash_temp", erd_override="lower_setting", icon_override="mdi:coolant-temperature"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "rinse_aid", erd_override="lower_setting", icon_override="mdi:shimmer"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "dry_option", erd_override="lower_setting", icon_override="mdi:fan"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "wash_zone", erd_override="lower_setting", icon_override="mdi:dock-top"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "delay_hours", erd_override="lower_setting", icon_override="mdi:clock-fast") + ] + + upper_entities = [ + #GeDishwasherControlLockedSwitch(self, ErdCode.USER_INTERFACE_LOCKED), + GeErdSensor(self, ErdCode.DISHWASHER_UPPER_CYCLE_STATE, erd_override="upper_cycle_state", icon_override="mdi:state-machine"), + GeErdSensor(self, ErdCode.DISHWASHER_UPPER_TIME_REMAINING, erd_override="upper_time_remaining"), + GeErdBinarySensor(self, ErdCode.DISHWASHER_UPPER_DOOR_STATUS, erd_override="upper_door_status"), + + #Reminders + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_REMINDERS, "add_rinse_aid", erd_override="upper_reminder", icon_override="mdi:shimmer"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_REMINDERS, "clean_filter", erd_override="upper_reminder", icon_override="mdi:dishwasher-alert"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_REMINDERS, "sanitized", erd_override="upper_reminder", icon_override="mdi:silverware-clean"), + + #User Setttings + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_USER_SETTING, "mute", erd_override="upper_setting", icon_override="mdi:volume-mute"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_USER_SETTING, "lock_control", erd_override="upper_setting", icon_override="mdi:lock"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_USER_SETTING, "sabbath", erd_override="upper_setting", icon_override="mdi:star-david"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_USER_SETTING, "cycle_mode", erd_override="upper_setting", icon_override="mdi:state-machine"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_USER_SETTING, "presoak", erd_override="upper_setting", icon_override="mdi:water"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_USER_SETTING, "bottle_jet", erd_override="upper_setting", icon_override="mdi:bottle-tonic-outline"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_USER_SETTING, "wash_temp", erd_override="upper_setting", icon_override="mdi:coolant-temperature"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_USER_SETTING, "rinse_aid", erd_override="upper_setting", icon_override="mdi:shimmer"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_USER_SETTING, "dry_option", erd_override="upper_setting", icon_override="mdi:fan"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_USER_SETTING, "wash_zone", erd_override="upper_setting", icon_override="mdi:dock-top"), + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_USER_SETTING, "delay_hours", erd_override="upper_setting", icon_override="mdi:clock-fast") + ] + + entities = base_entities + lower_entities + upper_entities + return entities + diff --git a/custom_components/ge_home/devices/espresso_maker.py b/custom_components/ge_home/devices/espresso_maker.py new file mode 100644 index 0000000..efb184e --- /dev/null +++ b/custom_components/ge_home/devices/espresso_maker.py @@ -0,0 +1,34 @@ +import logging +from typing import List + +from homeassistant.helpers.entity import Entity +from gehomesdk import ( + ErdCode, + ErdApplianceType +) + +from .base import ApplianceApi +from ..entities import ( + GeErdBinarySensor, + GeErdButton +) + +_LOGGER = logging.getLogger(__name__) + + +class EspressoMakerApi(ApplianceApi): + """API class for Espresso Maker objects""" + APPLIANCE_TYPE = ErdApplianceType.ESPRESSO_MAKER + + def get_all_entities(self) -> List[Entity]: + base_entities = super().get_all_entities() + + em_entities = [ + GeErdBinarySensor(self, ErdCode.CCM_IS_DESCALING), + GeErdButton(self, ErdCode.CCM_CANCEL_DESCALING), + GeErdButton(self, ErdCode.CCM_START_DESCALING), + GeErdBinarySensor(self, ErdCode.CCM_OUT_OF_WATER, device_class_override="problem"), + ] + + entities = base_entities + em_entities + return entities diff --git a/custom_components/ge_home/devices/fridge.py b/custom_components/ge_home/devices/fridge.py new file mode 100644 index 0000000..5f5dd8c --- /dev/null +++ b/custom_components/ge_home/devices/fridge.py @@ -0,0 +1,138 @@ +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import SensorDeviceClass +import logging +from typing import List + +from homeassistant.helpers.entity import Entity +from gehomesdk import ( + ErdCode, + ErdApplianceType, + ErdOnOff, + ErdHotWaterStatus, + FridgeIceBucketStatus, + IceMakerControlStatus, + ErdFilterStatus, + HotWaterStatus, + FridgeModelInfo, + ErdConvertableDrawerMode, + ErdDataType +) + +from .base import ApplianceApi +from ..entities import ( + ErdOnOffBoolConverter, + GeErdSensor, + GeErdBinarySensor, + GeErdSwitch, + GeErdSelect, + GeErdLight, + GeFridge, + GeFreezer, + GeDispenser, + GeErdPropertySensor, + GeErdPropertyBinarySensor, + ConvertableDrawerModeOptionsConverter, + GeFridgeIceControlSwitch +) + +_LOGGER = logging.getLogger(__name__) + +class FridgeApi(ApplianceApi): + """API class for fridge objects""" + APPLIANCE_TYPE = ErdApplianceType.FRIDGE + + def get_all_entities(self) -> List[Entity]: + base_entities = super().get_all_entities() + + fridge_entities = [] + freezer_entities = [] + dispenser_entities = [] + + # Get the statuses used to determine presence + + ice_maker_control: IceMakerControlStatus = self.try_get_erd_value(ErdCode.ICE_MAKER_CONTROL) + ice_bucket_status: FridgeIceBucketStatus = self.try_get_erd_value(ErdCode.ICE_MAKER_BUCKET_STATUS) + water_filter: ErdFilterStatus = self.try_get_erd_value(ErdCode.WATER_FILTER_STATUS) + air_filter: ErdFilterStatus = self.try_get_erd_value(ErdCode.AIR_FILTER_STATUS) + hot_water_status: HotWaterStatus = self.try_get_erd_value(ErdCode.HOT_WATER_STATUS) + fridge_model_info: FridgeModelInfo = self.try_get_erd_value(ErdCode.FRIDGE_MODEL_INFO) + convertable_drawer: ErdConvertableDrawerMode = self.try_get_erd_value(ErdCode.CONVERTABLE_DRAWER_MODE) + + interior_light: int = self.try_get_erd_value(ErdCode.INTERIOR_LIGHT) + proximity_light: ErdOnOff = self.try_get_erd_value(ErdCode.PROXIMITY_LIGHT) + display_mode: ErdOnOff = self.try_get_erd_value(ErdCode.DISPLAY_MODE) + lockout_mode: ErdOnOff = self.try_get_erd_value(ErdCode.LOCKOUT_MODE) + turbo_cool: ErdOnOff = self.try_get_erd_value(ErdCode.TURBO_COOL_STATUS) + turbo_freeze: ErdOnOff = self.try_get_erd_value(ErdCode.TURBO_FREEZE_STATUS) + ice_boost: ErdOnOff = self.try_get_erd_value(ErdCode.FRIDGE_ICE_BOOST) + + units = self.hass.config.units + + # Common entities + common_entities = [ + GeErdSensor(self, ErdCode.FRIDGE_MODEL_INFO), + GeErdSwitch(self, ErdCode.SABBATH_MODE), + GeErdSensor(self, ErdCode.DOOR_STATUS), + GeErdPropertyBinarySensor(self, ErdCode.DOOR_STATUS, "any_open") + ] + if(ice_bucket_status and (ice_bucket_status.is_present_fridge or ice_bucket_status.is_present_freezer)): + common_entities.append(GeErdSensor(self, ErdCode.ICE_MAKER_BUCKET_STATUS)) + + # Fridge entities + if fridge_model_info is None or fridge_model_info.has_fridge: + fridge_entities.extend([ + GeErdPropertySensor(self, ErdCode.CURRENT_TEMPERATURE, "fridge"), + GeFridge(self), + ]) + if turbo_cool is not None: + fridge_entities.append(GeErdSwitch(self, ErdCode.FRIDGE_ICE_BOOST)) + if(ice_maker_control and ice_maker_control.status_fridge != ErdOnOff.NA): + fridge_entities.append(GeErdPropertyBinarySensor(self, ErdCode.ICE_MAKER_CONTROL, "status_fridge")) + fridge_entities.append(GeFridgeIceControlSwitch(self, "fridge")) + if(water_filter and water_filter != ErdFilterStatus.NA): + fridge_entities.append(GeErdSensor(self, ErdCode.WATER_FILTER_STATUS)) + if(air_filter and air_filter != ErdFilterStatus.NA): + fridge_entities.append(GeErdSensor(self, ErdCode.AIR_FILTER_STATUS)) + if(ice_bucket_status and ice_bucket_status.is_present_fridge): + fridge_entities.append(GeErdPropertySensor(self, ErdCode.ICE_MAKER_BUCKET_STATUS, "state_full_fridge")) + if(interior_light and interior_light != 255): + fridge_entities.append(GeErdLight(self, ErdCode.INTERIOR_LIGHT)) + if(proximity_light and proximity_light != ErdOnOff.NA): + fridge_entities.append(GeErdSwitch(self, ErdCode.PROXIMITY_LIGHT, ErdOnOffBoolConverter(), icon_on_override="mdi:lightbulb-on", icon_off_override="mdi:lightbulb")) + if(convertable_drawer and convertable_drawer != ErdConvertableDrawerMode.NA): + fridge_entities.append(GeErdSelect(self, ErdCode.CONVERTABLE_DRAWER_MODE, ConvertableDrawerModeOptionsConverter(units))) + if(display_mode and display_mode != ErdOnOff.NA): + fridge_entities.append(GeErdSwitch(self, ErdCode.DISPLAY_MODE, ErdOnOffBoolConverter(), icon_on_override="mdi:lightbulb-on", icon_off_override="mdi:lightbulb")) + if(lockout_mode and lockout_mode != ErdOnOff.NA): + fridge_entities.append(GeErdSwitch(self, ErdCode.LOCKOUT_MODE, ErdOnOffBoolConverter(), icon_on_override="mdi:lock", icon_off_override="mdi:lock-open")) + + # Freezer entities + if fridge_model_info is None or fridge_model_info.has_freezer: + freezer_entities.extend([ + GeErdPropertySensor(self, ErdCode.CURRENT_TEMPERATURE, "freezer"), + GeFreezer(self), + ]) + if turbo_freeze is not None: + freezer_entities.append(GeErdSwitch(self, ErdCode.TURBO_FREEZE_STATUS)) + if ice_boost is not None: + freezer_entities.append(GeErdSwitch(self, ErdCode.FRIDGE_ICE_BOOST)) + if(ice_maker_control and ice_maker_control.status_freezer != ErdOnOff.NA): + freezer_entities.append(GeErdPropertyBinarySensor(self, ErdCode.ICE_MAKER_CONTROL, "status_freezer")) + freezer_entities.append(GeFridgeIceControlSwitch(self, "freezer")) + if(ice_bucket_status and ice_bucket_status.is_present_freezer): + freezer_entities.append(GeErdPropertySensor(self, ErdCode.ICE_MAKER_BUCKET_STATUS, "state_full_freezer")) + + # Dispenser entities + if(hot_water_status and hot_water_status.status != ErdHotWaterStatus.NA): + dispenser_entities.extend([ + GeErdBinarySensor(self, ErdCode.HOT_WATER_IN_USE), + GeErdSensor(self, ErdCode.HOT_WATER_SET_TEMP), + GeErdPropertySensor(self, ErdCode.HOT_WATER_STATUS, "status", icon_override="mdi:information-outline"), + GeErdPropertySensor(self, ErdCode.HOT_WATER_STATUS, "time_until_ready", icon_override="mdi:timer-outline"), + GeErdPropertySensor(self, ErdCode.HOT_WATER_STATUS, "current_temp", device_class_override=SensorDeviceClass.TEMPERATURE, data_type_override=ErdDataType.INT), + GeErdPropertyBinarySensor(self, ErdCode.HOT_WATER_STATUS, "faulted", device_class_override=BinarySensorDeviceClass.PROBLEM), + GeDispenser(self) + ]) + + entities = base_entities + common_entities + fridge_entities + freezer_entities + dispenser_entities + return entities diff --git a/custom_components/ge_home/devices/hood.py b/custom_components/ge_home/devices/hood.py new file mode 100644 index 0000000..439c775 --- /dev/null +++ b/custom_components/ge_home/devices/hood.py @@ -0,0 +1,52 @@ +import logging +from typing import List + +from homeassistant.helpers.entity import Entity +from gehomesdk import ( + ErdCode, + ErdApplianceType, + ErdHoodFanSpeedAvailability, + ErdHoodLightLevelAvailability, + ErdOnOff +) + +from .base import ApplianceApi +from ..entities import ( + GeHoodLightLevelSelect, + GeHoodFanSpeedSelect, + GeErdTimerSensor, + GeErdSwitch, + ErdOnOffBoolConverter +) + +_LOGGER = logging.getLogger(__name__) + + +class HoodApi(ApplianceApi): + """API class for Oven Hood objects""" + APPLIANCE_TYPE = ErdApplianceType.HOOD + + def get_all_entities(self) -> List[Entity]: + base_entities = super().get_all_entities() + + #get the availabilities + fan_availability: ErdHoodFanSpeedAvailability = self.try_get_erd_value(ErdCode.HOOD_FAN_SPEED_AVAILABILITY) + light_availability: ErdHoodLightLevelAvailability = self.try_get_erd_value(ErdCode.HOOD_LIGHT_LEVEL_AVAILABILITY) + timer_availability: ErdOnOff = self.try_get_erd_value(ErdCode.HOOD_TIMER_AVAILABILITY) + + hood_entities = [ + #looks like this is always available? + GeErdSwitch(self, ErdCode.HOOD_DELAY_OFF, bool_converter=ErdOnOffBoolConverter(), icon_on_override="mdi:power-on", icon_off_override="mdi:power-off"), + ] + + if fan_availability and fan_availability.is_available: + hood_entities.append(GeHoodFanSpeedSelect(self, ErdCode.HOOD_FAN_SPEED)) + #for now, represent as a select + if light_availability and light_availability.is_available: + hood_entities.append(GeHoodLightLevelSelect(self, ErdCode.HOOD_LIGHT_LEVEL)) + if timer_availability == ErdOnOff.ON: + hood_entities.append(GeErdTimerSensor(self, ErdCode.HOOD_TIMER)) + + entities = base_entities + hood_entities + return entities + diff --git a/custom_components/ge_home/devices/microwave.py b/custom_components/ge_home/devices/microwave.py new file mode 100644 index 0000000..ec943fa --- /dev/null +++ b/custom_components/ge_home/devices/microwave.py @@ -0,0 +1,56 @@ +import logging +from typing import List + +from homeassistant.helpers.entity import Entity +from gehomesdk import ( + ErdCode, + ErdApplianceType, + ErdHoodFanSpeedAvailability, + ErdHoodLightLevelAvailability, + ErdOnOff +) + +from .base import ApplianceApi +from ..entities import ( + GeHoodLightLevelSelect, + GeHoodFanSpeedSelect, + GeErdPropertySensor, + GeErdPropertyBinarySensor, + GeErdBinarySensor, + GeErdTimerSensor +) + +_LOGGER = logging.getLogger(__name__) + + +class MicrowaveApi(ApplianceApi): + """API class for Microwave objects""" + APPLIANCE_TYPE = ErdApplianceType.MICROWAVE + + def get_all_entities(self) -> List[Entity]: + base_entities = super().get_all_entities() + + #get the availabilities + fan_availability: ErdHoodFanSpeedAvailability = self.try_get_erd_value(ErdCode.HOOD_FAN_SPEED_AVAILABILITY) + light_availability: ErdHoodLightLevelAvailability = self.try_get_erd_value(ErdCode.HOOD_LIGHT_LEVEL_AVAILABILITY) + + mwave_entities = [ + GeErdBinarySensor(self, ErdCode.MICROWAVE_REMOTE_ENABLE), + GeErdPropertySensor(self, ErdCode.MICROWAVE_STATE, "status"), + GeErdPropertyBinarySensor(self, ErdCode.MICROWAVE_STATE, "door_status", device_class_override="door"), + GeErdPropertySensor(self, ErdCode.MICROWAVE_STATE, "cook_mode", icon_override="mdi:food-turkey"), + GeErdPropertySensor(self, ErdCode.MICROWAVE_STATE, "power_level", icon_override="mdi:gauge"), + GeErdPropertySensor(self, ErdCode.MICROWAVE_STATE, "temperature", icon_override="mdi:thermometer"), + GeErdTimerSensor(self, ErdCode.MICROWAVE_COOK_TIMER), + GeErdTimerSensor(self, ErdCode.MICROWAVE_KITCHEN_TIMER) + ] + + if fan_availability and fan_availability.is_available: + mwave_entities.append(GeHoodFanSpeedSelect(self, ErdCode.HOOD_FAN_SPEED)) + #for now, represent as a select + if light_availability and light_availability.is_available: + mwave_entities.append(GeHoodLightLevelSelect(self, ErdCode.HOOD_LIGHT_LEVEL)) + + entities = base_entities + mwave_entities + return entities + diff --git a/custom_components/ge_home/devices/oim.py b/custom_components/ge_home/devices/oim.py new file mode 100644 index 0000000..2eebd39 --- /dev/null +++ b/custom_components/ge_home/devices/oim.py @@ -0,0 +1,41 @@ +import logging +from typing import List + +from homeassistant.helpers.entity import Entity +from gehomesdk import ( + ErdCode, + ErdApplianceType, + ErdOnOff +) + +from .base import ApplianceApi +from ..entities import ( + OimLightLevelOptionsConverter, + GeErdSensor, + GeErdBinarySensor, + GeErdSelect, + GeErdSwitch, + ErdOnOffBoolConverter +) + +_LOGGER = logging.getLogger(__name__) + + +class OimApi(ApplianceApi): + """API class for Opal Ice Maker objects""" + APPLIANCE_TYPE = ErdApplianceType.OPAL_ICE_MAKER + + def get_all_entities(self) -> List[Entity]: + base_entities = super().get_all_entities() + + oim_entities = [ + GeErdSensor(self, ErdCode.OIM_STATUS), + GeErdBinarySensor(self, ErdCode.OIM_FILTER_STATUS, device_class_override="problem"), + GeErdBinarySensor(self, ErdCode.OIM_NEEDS_DESCALING, device_class_override="problem"), + GeErdSelect(self, ErdCode.OIM_LIGHT_LEVEL, OimLightLevelOptionsConverter()), + GeErdSwitch(self, ErdCode.OIM_POWER, bool_converter=ErdOnOffBoolConverter(), icon_on_override="mdi:power-on", icon_off_override="mdi:power-off"), + ] + + entities = base_entities + oim_entities + return entities + diff --git a/custom_components/ge_home/devices/oven.py b/custom_components/ge_home/devices/oven.py new file mode 100644 index 0000000..914b38e --- /dev/null +++ b/custom_components/ge_home/devices/oven.py @@ -0,0 +1,146 @@ +import logging +from typing import List +from gehomesdk.erd.erd_data_type import ErdDataType + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.helpers.entity import Entity +from gehomesdk import ( + ErdCode, + ErdApplianceType, + OvenConfiguration, + ErdCooktopConfig, + CooktopStatus, + ErdOvenLightLevel, + ErdOvenLightLevelAvailability, + ErdOvenWarmingState +) + +from .base import ApplianceApi +from ..entities import ( + GeErdSensor, + GeErdTimerSensor, + GeErdBinarySensor, + GeErdPropertySensor, + GeErdPropertyBinarySensor, + GeOven, + GeOvenLightLevelSelect, + GeOvenWarmingStateSelect, + UPPER_OVEN, + LOWER_OVEN +) + +_LOGGER = logging.getLogger(__name__) + +class OvenApi(ApplianceApi): + """API class for oven objects""" + APPLIANCE_TYPE = ErdApplianceType.OVEN + + def get_all_entities(self) -> List[Entity]: + base_entities = super().get_all_entities() + oven_config: OvenConfiguration = self.appliance.get_erd_value(ErdCode.OVEN_CONFIGURATION) + + cooktop_config = ErdCooktopConfig.NONE + if self.has_erd_code(ErdCode.COOKTOP_CONFIG): + cooktop_config: ErdCooktopConfig = self.appliance.get_erd_value(ErdCode.COOKTOP_CONFIG) + + has_upper_raw_temperature = self.has_erd_code(ErdCode.UPPER_OVEN_RAW_TEMPERATURE) + has_lower_raw_temperature = self.has_erd_code(ErdCode.LOWER_OVEN_RAW_TEMPERATURE) + + has_upper_probe_temperature = self.has_erd_code(ErdCode.UPPER_OVEN_PROBE_DISPLAY_TEMP) + has_lower_probe_temperature = self.has_erd_code(ErdCode.LOWER_OVEN_PROBE_DISPLAY_TEMP) + + upper_light : ErdOvenLightLevel = self.try_get_erd_value(ErdCode.UPPER_OVEN_LIGHT) + upper_light_availability: ErdOvenLightLevelAvailability = self.try_get_erd_value(ErdCode.UPPER_OVEN_LIGHT_AVAILABILITY) + lower_light : ErdOvenLightLevel = self.try_get_erd_value(ErdCode.LOWER_OVEN_LIGHT) + lower_light_availability: ErdOvenLightLevelAvailability = self.try_get_erd_value(ErdCode.LOWER_OVEN_LIGHT_AVAILABILITY) + + upper_warm_drawer : ErdOvenWarmingState = self.try_get_erd_value(ErdCode.UPPER_OVEN_WARMING_DRAWER_STATE) + lower_warm_drawer : ErdOvenWarmingState = self.try_get_erd_value(ErdCode.LOWER_OVEN_WARMING_DRAWER_STATE) + warm_drawer : ErdOvenWarmingState = self.try_get_erd_value(ErdCode.WARMING_DRAWER_STATE) + + _LOGGER.debug(f"Oven Config: {oven_config}") + _LOGGER.debug(f"Cooktop Config: {cooktop_config}") + oven_entities = [] + cooktop_entities = [] + + if oven_config.has_lower_oven: + oven_entities.extend([ + GeErdSensor(self, ErdCode.LOWER_OVEN_COOK_MODE), + GeErdSensor(self, ErdCode.LOWER_OVEN_CURRENT_STATE), + GeErdSensor(self, ErdCode.LOWER_OVEN_COOK_TIME_REMAINING), + GeErdTimerSensor(self, ErdCode.LOWER_OVEN_KITCHEN_TIMER), + GeErdSensor(self, ErdCode.LOWER_OVEN_USER_TEMP_OFFSET), + GeErdSensor(self, ErdCode.LOWER_OVEN_DISPLAY_TEMPERATURE), + GeErdBinarySensor(self, ErdCode.LOWER_OVEN_REMOTE_ENABLED), + + GeOven(self, LOWER_OVEN, True, self._temperature_code(has_lower_raw_temperature)) + ]) + if has_lower_raw_temperature: + oven_entities.append(GeErdSensor(self, ErdCode.LOWER_OVEN_RAW_TEMPERATURE)) + if lower_light_availability is None or lower_light_availability.is_available or lower_light is not None: + oven_entities.append(GeOvenLightLevelSelect(self, ErdCode.LOWER_OVEN_LIGHT)) + if lower_warm_drawer is not None: + oven_entities.append(GeOvenWarmingStateSelect(self, ErdCode.LOWER_OVEN_WARMING_DRAWER_STATE)) + if has_lower_probe_temperature: + oven_entities.append(GeErdSensor(self, ErdCode.LOWER_OVEN_PROBE_DISPLAY_TEMP)) + + oven_entities.extend([ + GeErdSensor(self, ErdCode.UPPER_OVEN_COOK_MODE, self._single_name(ErdCode.UPPER_OVEN_COOK_MODE, ~oven_config.has_lower_oven)), + GeErdSensor(self, ErdCode.UPPER_OVEN_CURRENT_STATE, self._single_name(ErdCode.UPPER_OVEN_CURRENT_STATE, ~oven_config.has_lower_oven)), + GeErdSensor(self, ErdCode.UPPER_OVEN_COOK_TIME_REMAINING, self._single_name(ErdCode.UPPER_OVEN_COOK_TIME_REMAINING, ~oven_config.has_lower_oven)), + GeErdTimerSensor(self, ErdCode.UPPER_OVEN_KITCHEN_TIMER, self._single_name(ErdCode.UPPER_OVEN_KITCHEN_TIMER, ~oven_config.has_lower_oven)), + GeErdSensor(self, ErdCode.UPPER_OVEN_USER_TEMP_OFFSET, self._single_name(ErdCode.UPPER_OVEN_USER_TEMP_OFFSET, ~oven_config.has_lower_oven)), + GeErdSensor(self, ErdCode.UPPER_OVEN_DISPLAY_TEMPERATURE, self._single_name(ErdCode.UPPER_OVEN_DISPLAY_TEMPERATURE, ~oven_config.has_lower_oven)), + GeErdBinarySensor(self, ErdCode.UPPER_OVEN_REMOTE_ENABLED, self._single_name(ErdCode.UPPER_OVEN_REMOTE_ENABLED, ~oven_config.has_lower_oven)), + + GeOven(self, UPPER_OVEN, False, self._temperature_code(has_upper_raw_temperature)) + ]) + if has_upper_raw_temperature: + oven_entities.append(GeErdSensor(self, ErdCode.UPPER_OVEN_RAW_TEMPERATURE, self._single_name(ErdCode.UPPER_OVEN_RAW_TEMPERATURE, ~oven_config.has_lower_oven))) + if upper_light_availability is None or upper_light_availability.is_available or upper_light is not None: + oven_entities.append(GeOvenLightLevelSelect(self, ErdCode.UPPER_OVEN_LIGHT, self._single_name(ErdCode.UPPER_OVEN_LIGHT, ~oven_config.has_lower_oven))) + if upper_warm_drawer is not None: + oven_entities.append(GeOvenWarmingStateSelect(self, ErdCode.UPPER_OVEN_WARMING_DRAWER_STATE, self._single_name(ErdCode.UPPER_OVEN_WARMING_DRAWER_STATE, ~oven_config.has_lower_oven))) + if has_upper_probe_temperature: + oven_entities.append(GeErdSensor(self, ErdCode.UPPER_OVEN_PROBE_DISPLAY_TEMP, self._single_name(ErdCode.UPPER_OVEN_PROBE_DISPLAY_TEMP, ~oven_config.has_lower_oven))) + + if oven_config.has_warming_drawer and warm_drawer is not None: + oven_entities.append(GeErdSensor(self, ErdCode.WARMING_DRAWER_STATE)) + + if cooktop_config == ErdCooktopConfig.PRESENT: + # attempt to get the cooktop status using legacy status + cooktop_status_erd = ErdCode.COOKTOP_STATUS + cooktop_status: CooktopStatus = self.try_get_erd_value(ErdCode.COOKTOP_STATUS) + + # if we didn't get it, try using the new version + if cooktop_status is None: + cooktop_status_erd = ErdCode.COOKTOP_STATUS_EXT + cooktop_status: CooktopStatus = self.try_get_erd_value(ErdCode.COOKTOP_STATUS_EXT) + + # if we got a status through either mechanism, we can add the entities + if cooktop_status is not None: + cooktop_entities.append(GeErdBinarySensor(self, cooktop_status_erd)) + + for (k, v) in cooktop_status.burners.items(): + if v.exists: + prop = self._camel_to_snake(k) + cooktop_entities.append(GeErdPropertyBinarySensor(self, cooktop_status_erd, prop+".on")) + cooktop_entities.append(GeErdPropertyBinarySensor(self, cooktop_status_erd, prop+".synchronized")) + if not v.on_off_only: + cooktop_entities.append(GeErdPropertySensor(self, cooktop_status_erd, prop+".power_pct", icon_override="mdi:fire", device_class_override=SensorDeviceClass.POWER_FACTOR, data_type_override=ErdDataType.INT)) + + return base_entities + oven_entities + cooktop_entities + + def _single_name(self, erd_code: ErdCode, make_single: bool): + name = erd_code.name + + if make_single: + name = name.replace(UPPER_OVEN+"_","") + + return name.replace("_", " ").title() + + def _camel_to_snake(self, s): + return ''.join(['_'+c.lower() if c.isupper() else c for c in s]).lstrip('_') + + def _temperature_code(self, has_raw: bool): + return "RAW_TEMPERATURE" if has_raw else "DISPLAY_TEMPERATURE" diff --git a/custom_components/ge_home/devices/pac.py b/custom_components/ge_home/devices/pac.py new file mode 100644 index 0000000..fa2da9d --- /dev/null +++ b/custom_components/ge_home/devices/pac.py @@ -0,0 +1,31 @@ +import logging +from typing import List + +from homeassistant.helpers.entity import Entity +from gehomesdk.erd import ErdCode, ErdApplianceType + +from .base import ApplianceApi +from ..entities import GePacClimate, GeErdSensor, GeErdSwitch, ErdOnOffBoolConverter + +_LOGGER = logging.getLogger(__name__) + + +class PacApi(ApplianceApi): + """API class for Portable AC objects""" + APPLIANCE_TYPE = ErdApplianceType.PORTABLE_AIR_CONDITIONER + + def get_all_entities(self) -> List[Entity]: + base_entities = super().get_all_entities() + + pac_entities = [ + GePacClimate(self), + GeErdSensor(self, ErdCode.AC_TARGET_TEMPERATURE), + GeErdSensor(self, ErdCode.AC_AMBIENT_TEMPERATURE), + GeErdSensor(self, ErdCode.AC_FAN_SETTING, icon_override="mdi:fan"), + GeErdSensor(self, ErdCode.AC_OPERATION_MODE), + GeErdSwitch(self, ErdCode.AC_POWER_STATUS, bool_converter=ErdOnOffBoolConverter(), icon_on_override="mdi:power-on", icon_off_override="mdi:power-off"), + ] + + entities = base_entities + pac_entities + return entities + diff --git a/custom_components/ge_home/devices/sac.py b/custom_components/ge_home/devices/sac.py new file mode 100644 index 0000000..a1dfad5 --- /dev/null +++ b/custom_components/ge_home/devices/sac.py @@ -0,0 +1,37 @@ +import logging +from typing import List + +from homeassistant.helpers.entity import Entity +from gehomesdk.erd import ErdCode, ErdApplianceType + +from .base import ApplianceApi +from ..entities import GeSacClimate, GeErdSensor, GeErdSwitch, ErdOnOffBoolConverter + +_LOGGER = logging.getLogger(__name__) + + +class SacApi(ApplianceApi): + """API class for Split AC objects""" + APPLIANCE_TYPE = ErdApplianceType.SPLIT_AIR_CONDITIONER + + def get_all_entities(self) -> List[Entity]: + base_entities = super().get_all_entities() + + sac_entities = [ + GeSacClimate(self), + GeErdSensor(self, ErdCode.AC_TARGET_TEMPERATURE), + GeErdSensor(self, ErdCode.AC_AMBIENT_TEMPERATURE), + GeErdSensor(self, ErdCode.AC_FAN_SETTING, icon_override="mdi:fan"), + GeErdSensor(self, ErdCode.AC_OPERATION_MODE), + GeErdSwitch(self, ErdCode.AC_POWER_STATUS, bool_converter=ErdOnOffBoolConverter(), icon_on_override="mdi:power-on", icon_off_override="mdi:power-off"), + ] + + if self.has_erd_code(ErdCode.SAC_SLEEP_MODE): + sac_entities.append(GeErdSwitch(self, ErdCode.SAC_SLEEP_MODE, bool_converter=ErdOnOffBoolConverter(), icon_on_override="mdi:sleep", icon_off_override="mdi:sleep-off")) + if self.has_erd_code(ErdCode.SAC_AUTO_SWING_MODE): + sac_entities.append(GeErdSwitch(self, ErdCode.SAC_AUTO_SWING_MODE, bool_converter=ErdOnOffBoolConverter(), icon_on_override="mdi:arrow-decision-auto", icon_off_override="mdi:arrow-decision-auto-outline")) + + + entities = base_entities + sac_entities + return entities + diff --git a/custom_components/ge_home/devices/ucim.py b/custom_components/ge_home/devices/ucim.py new file mode 100644 index 0000000..f4b60e3 --- /dev/null +++ b/custom_components/ge_home/devices/ucim.py @@ -0,0 +1,45 @@ +import logging +from typing import List + +from homeassistant.helpers.entity import Entity +from gehomesdk import ( + ErdCode, + ErdApplianceType, + ErdOnOff +) + +from .base import ApplianceApi +from ..entities import ( + OimLightLevelOptionsConverter, + GeErdSensor, + GeErdBinarySensor, + GeErdSelect, + GeErdSwitch, + ErdOnOffBoolConverter +) + +_LOGGER = logging.getLogger(__name__) + + +class UcimApi(ApplianceApi): + """API class for Opal Ice Maker objects""" + APPLIANCE_TYPE = ErdApplianceType.OPAL_ICE_MAKER + + def get_all_entities(self) -> List[Entity]: + base_entities = super().get_all_entities() + + oim_entities = [ + GeErdSensor(self, ErdCode.OIM_STATUS), + GeErdBinarySensor(self, ErdCode.OIM_FILTER_STATUS, device_class_override="problem"), + GeErdBinarySensor(self, ErdCode.OIM_NEEDS_DESCALING, device_class_override="problem"), + GeErdSelect(self, ErdCode.OIM_LIGHT_LEVEL, OimLightLevelOptionsConverter()), + GeErdSwitch(self, ErdCode.OIM_POWER, bool_converter=ErdOnOffBoolConverter(), icon_on_override="mdi:power-on", icon_off_override="mdi:power-off"), + GeErdSensor(self, ErdCode.OIM_PRODUCTION), + GeErdSensor(self, ErdCode.UCIM_CLEAN_STATUS), + GeErdSensor(self, ErdCode.UCIM_FILTER_PERCENTAGE_USED), + GeErdBinarySensor(self, ErdCode.UCIM_BIN_FULL, device_class_override="problem"), + ] + + entities = base_entities + oim_entities + return entities + diff --git a/custom_components/ge_home/devices/wac.py b/custom_components/ge_home/devices/wac.py new file mode 100644 index 0000000..6208a82 --- /dev/null +++ b/custom_components/ge_home/devices/wac.py @@ -0,0 +1,33 @@ +import logging +from typing import List + +from homeassistant.helpers.entity import Entity +from gehomesdk.erd import ErdCode, ErdApplianceType + +from .base import ApplianceApi +from ..entities import GeWacClimate, GeErdSensor, GeErdBinarySensor, GeErdSwitch, ErdOnOffBoolConverter + +_LOGGER = logging.getLogger(__name__) + + +class WacApi(ApplianceApi): + """API class for Window AC objects""" + APPLIANCE_TYPE = ErdApplianceType.AIR_CONDITIONER + + def get_all_entities(self) -> List[Entity]: + base_entities = super().get_all_entities() + + wac_entities = [ + GeWacClimate(self), + GeErdSensor(self, ErdCode.AC_TARGET_TEMPERATURE), + GeErdSensor(self, ErdCode.AC_AMBIENT_TEMPERATURE), + GeErdSensor(self, ErdCode.AC_FAN_SETTING, icon_override="mdi:fan"), + GeErdSensor(self, ErdCode.AC_OPERATION_MODE), + GeErdSwitch(self, ErdCode.AC_POWER_STATUS, bool_converter=ErdOnOffBoolConverter(), icon_on_override="mdi:power-on", icon_off_override="mdi:power-off"), + GeErdBinarySensor(self, ErdCode.AC_FILTER_STATUS, device_class_override="problem"), + GeErdSensor(self, ErdCode.WAC_DEMAND_RESPONSE_STATE), + GeErdSensor(self, ErdCode.WAC_DEMAND_RESPONSE_POWER, uom_override="kW"), + ] + entities = base_entities + wac_entities + return entities + diff --git a/custom_components/ge_home/devices/washer.py b/custom_components/ge_home/devices/washer.py new file mode 100644 index 0000000..d832403 --- /dev/null +++ b/custom_components/ge_home/devices/washer.py @@ -0,0 +1,63 @@ +import logging +from typing import List + +from homeassistant.helpers.entity import Entity +from gehomesdk import ErdCode, ErdApplianceType + +from .base import ApplianceApi +from ..entities import GeErdSensor, GeErdBinarySensor, GeErdPropertySensor + +_LOGGER = logging.getLogger(__name__) + + +class WasherApi(ApplianceApi): + """API class for washer objects""" + APPLIANCE_TYPE = ErdApplianceType.WASHER + + def get_all_entities(self) -> List[Entity]: + base_entities = super().get_all_entities() + + common_entities = [ + GeErdSensor(self, ErdCode.LAUNDRY_MACHINE_STATE), + GeErdSensor(self, ErdCode.LAUNDRY_CYCLE, icon_override="mdi:state-machine"), + GeErdSensor(self, ErdCode.LAUNDRY_SUB_CYCLE, icon_override="mdi:state-machine"), + GeErdBinarySensor(self, ErdCode.LAUNDRY_END_OF_CYCLE), + GeErdSensor(self, ErdCode.LAUNDRY_TIME_REMAINING, suggested_uom="min"), + GeErdSensor(self, ErdCode.LAUNDRY_DELAY_TIME_REMAINING, suggested_uom="h"), + GeErdBinarySensor(self, ErdCode.LAUNDRY_DOOR), + GeErdBinarySensor(self, ErdCode.LAUNDRY_REMOTE_STATUS), + ] + + washer_entities = self.get_washer_entities() + + entities = base_entities + common_entities + washer_entities + return entities + + def get_washer_entities(self) -> List[Entity]: + washer_entities = [ + GeErdSensor(self, ErdCode.LAUNDRY_WASHER_SOIL_LEVEL, icon_override="mdi:emoticon-poop"), + GeErdSensor(self, ErdCode.LAUNDRY_WASHER_WASHTEMP_LEVEL), + GeErdSensor(self, ErdCode.LAUNDRY_WASHER_SPINTIME_LEVEL, icon_override="mdi:speedometer"), + GeErdSensor(self, ErdCode.LAUNDRY_WASHER_RINSE_OPTION, icon_override="mdi:shimmer"), + ] + + if self.has_erd_code(ErdCode.LAUNDRY_WASHER_DOOR_LOCK): + washer_entities.extend([GeErdBinarySensor(self, ErdCode.LAUNDRY_WASHER_DOOR_LOCK)]) + if self.has_erd_code(ErdCode.LAUNDRY_WASHER_TANK_STATUS): + washer_entities.extend([GeErdSensor(self, ErdCode.LAUNDRY_WASHER_TANK_STATUS)]) + if self.has_erd_code(ErdCode.LAUNDRY_WASHER_TANK_SELECTED): + washer_entities.extend([GeErdSensor(self, ErdCode.LAUNDRY_WASHER_TANK_SELECTED)]) + if self.has_erd_code(ErdCode.LAUNDRY_WASHER_TIMESAVER): + washer_entities.extend([GeErdBinarySensor(self, ErdCode.LAUNDRY_WASHER_TIMESAVER, icon_on_override="mdi:sort-clock-ascending", icon_off_override="mdi:sort-clock-ascending-outline")]) + if self.has_erd_code(ErdCode.LAUNDRY_WASHER_POWERSTEAM): + washer_entities.extend([GeErdBinarySensor(self, ErdCode.LAUNDRY_WASHER_POWERSTEAM, icon_on_override="mdi:kettle-steam", icon_off_override="mdi:kettle-steam-outline")]) + if self.has_erd_code(ErdCode.LAUNDRY_WASHER_PREWASH): + washer_entities.extend([GeErdBinarySensor(self, ErdCode.LAUNDRY_WASHER_PREWASH, icon_on_override="mdi:water-plus", icon_off_override="mdi:water-remove-outline")]) + if self.has_erd_code(ErdCode.LAUNDRY_WASHER_TUMBLECARE): + washer_entities.extend([GeErdBinarySensor(self, ErdCode.LAUNDRY_WASHER_TUMBLECARE)]) + if self.has_erd_code(ErdCode.LAUNDRY_WASHER_SMART_DISPENSE): + washer_entities.extend([GeErdPropertySensor(self, ErdCode.LAUNDRY_WASHER_SMART_DISPENSE, "loads_left", uom_override="loads")]) + if self.has_erd_code(ErdCode.LAUNDRY_WASHER_SMART_DISPENSE_TANK_STATUS): + washer_entities.extend([GeErdSensor(self, ErdCode.LAUNDRY_WASHER_SMART_DISPENSE_TANK_STATUS)]) + + return washer_entities diff --git a/custom_components/ge_home/devices/washer_dryer.py b/custom_components/ge_home/devices/washer_dryer.py new file mode 100644 index 0000000..f701ff2 --- /dev/null +++ b/custom_components/ge_home/devices/washer_dryer.py @@ -0,0 +1,36 @@ +import logging +from typing import List + +from homeassistant.helpers.entity import Entity +from gehomesdk import ErdCode, ErdApplianceType + +from .washer import WasherApi +from .dryer import DryerApi +from ..entities import GeErdSensor, GeErdBinarySensor + +_LOGGER = logging.getLogger(__name__) + +class WasherDryerApi(WasherApi, DryerApi): + """API class for washer/dryer objects""" + APPLIANCE_TYPE = ErdApplianceType.COMBINATION_WASHER_DRYER + + def get_all_entities(self) -> List[Entity]: + base_entities = self.get_base_entities() + + common_entities = [ + GeErdSensor(self, ErdCode.LAUNDRY_MACHINE_STATE), + GeErdSensor(self, ErdCode.LAUNDRY_CYCLE), + GeErdSensor(self, ErdCode.LAUNDRY_SUB_CYCLE), + GeErdBinarySensor(self, ErdCode.LAUNDRY_END_OF_CYCLE), + GeErdSensor(self, ErdCode.LAUNDRY_TIME_REMAINING), + GeErdSensor(self, ErdCode.LAUNDRY_DELAY_TIME_REMAINING), + GeErdBinarySensor(self, ErdCode.LAUNDRY_DOOR), + GeErdBinarySensor(self, ErdCode.LAUNDRY_REMOTE_STATUS), + ] + + washer_entities = self.get_washer_entities() + dryer_entities = self.get_dryer_entities() + + entities = base_entities + common_entities + washer_entities + dryer_entities + return entities + diff --git a/custom_components/ge_home/devices/water_filter.py b/custom_components/ge_home/devices/water_filter.py new file mode 100644 index 0000000..7cebd6a --- /dev/null +++ b/custom_components/ge_home/devices/water_filter.py @@ -0,0 +1,38 @@ +import logging +from typing import List + +from homeassistant.helpers.entity import Entity +from gehomesdk import ErdCode, ErdApplianceType + +from .base import ApplianceApi +from ..entities import ( + GeErdSensor, + GeErdPropertySensor, + GeErdBinarySensor, + GeErdFilterPositionSelect, +) + +_LOGGER = logging.getLogger(__name__) + + +class WaterFilterApi(ApplianceApi): + """API class for water filter objects""" + + APPLIANCE_TYPE = ErdApplianceType.POE_WATER_FILTER + + def get_all_entities(self) -> List[Entity]: + base_entities = super().get_all_entities() + + wf_entities = [ + GeErdSensor(self, ErdCode.WH_FILTER_MODE), + GeErdSensor(self, ErdCode.WH_FILTER_VALVE_STATE, icon_override="mdi:state-machine"), + GeErdFilterPositionSelect(self, ErdCode.WH_FILTER_POSITION), + GeErdBinarySensor(self, ErdCode.WH_FILTER_MANUAL_MODE, icon_on_override="mdi:human", icon_off_override="mdi:robot"), + GeErdBinarySensor(self, ErdCode.WH_FILTER_LEAK_VALIDITY, device_class_override="moisture"), + GeErdPropertySensor(self, ErdCode.WH_FILTER_FLOW_RATE, "flow_rate"), + GeErdSensor(self, ErdCode.WH_FILTER_DAY_USAGE, device_class_override="water"), + GeErdPropertySensor(self, ErdCode.WH_FILTER_LIFE_REMAINING, "life_remaining"), + GeErdBinarySensor(self, ErdCode.WH_FILTER_FLOW_ALERT, device_class_override="moisture"), + ] + entities = base_entities + wf_entities + return entities diff --git a/custom_components/ge_home/devices/water_heater.py b/custom_components/ge_home/devices/water_heater.py new file mode 100644 index 0000000..e571a75 --- /dev/null +++ b/custom_components/ge_home/devices/water_heater.py @@ -0,0 +1,54 @@ +import logging +from typing import List + +from homeassistant.helpers.entity import Entity +from gehomesdk import ( + ErdCode, + ErdApplianceType, + ErdOnOff +) + +from custom_components.ge_home.entities.water_heater.ge_water_heater import GeWaterHeater + +from .base import ApplianceApi +from ..entities import ( + GeErdSensor, + GeErdBinarySensor, + GeErdSelect, + GeErdSwitch, + ErdOnOffBoolConverter +) + +_LOGGER = logging.getLogger(__name__) + + +class WaterHeaterApi(ApplianceApi): + """API class for Water Heater objects""" + APPLIANCE_TYPE = ErdApplianceType.WATER_HEATER + + def get_all_entities(self) -> List[Entity]: + base_entities = super().get_all_entities() + + boost_mode: ErdOnOff = self.try_get_erd_value(ErdCode.WH_HEATER_BOOST_STATE) + active: ErdOnOff = self.try_get_erd_value(ErdCode.WH_HEATER_ACTIVE_STATE) + + wh_entities = [ + GeErdSensor(self, ErdCode.WH_HEATER_TARGET_TEMPERATURE), + GeErdSensor(self, ErdCode.WH_HEATER_TEMPERATURE), + GeErdSensor(self, ErdCode.WH_HEATER_MODE_HOURS_REMAINING), + GeErdSensor(self, ErdCode.WH_HEATER_ELECTRIC_MODE_MAX_TIME), + GeErdSensor(self, ErdCode.WH_HEATER_VACATION_MODE_MAX_TIME), + GeWaterHeater(self) + ] + + if(boost_mode and boost_mode != ErdOnOff.NA): + wh_entities.append(GeErdSensor(self, ErdCode.WH_HEATER_BOOST_STATE)) + wh_entities.append(GeErdSwitch(self, ErdCode.WH_HEATER_BOOST_CONTROL, ErdOnOffBoolConverter(), icon_on_override="mdi:rocket-launch", icon_off_override="mdi:rocket-launch-outline")) + + if(active and active != ErdOnOff.NA): + wh_entities.append(GeErdSensor(self, ErdCode.WH_HEATER_ACTIVE_STATE)) + wh_entities.append(GeErdSwitch(self, ErdCode.WH_HEATER_ACTIVE_CONTROL, ErdOnOffBoolConverter(), icon_on_override="mdi:power", icon_off_override="mdi:power-standby")) + + entities = base_entities + wh_entities + return entities + diff --git a/custom_components/ge_home/devices/water_softener.py b/custom_components/ge_home/devices/water_softener.py new file mode 100644 index 0000000..a0afa83 --- /dev/null +++ b/custom_components/ge_home/devices/water_softener.py @@ -0,0 +1,38 @@ +import logging +from typing import List + +from homeassistant.helpers.entity import Entity +from gehomesdk import ErdCode, ErdApplianceType + +from .base import ApplianceApi +from ..entities import ( + GeErdSensor, + GeErdPropertySensor, + GeErdBinarySensor, + GeErdShutoffPositionSelect, +) + +_LOGGER = logging.getLogger(__name__) + + +class WaterSoftenerApi(ApplianceApi): + """API class for water softener objects""" + + APPLIANCE_TYPE = ErdApplianceType.WATER_SOFTENER + + def get_all_entities(self) -> List[Entity]: + base_entities = super().get_all_entities() + + ws_entities = [ + GeErdBinarySensor(self, ErdCode.WH_FILTER_MANUAL_MODE, icon_on_override="mdi:human", icon_off_override="mdi:robot"), + GeErdPropertySensor(self, ErdCode.WH_FILTER_FLOW_RATE, "flow_rate"), + GeErdBinarySensor(self, ErdCode.WH_FILTER_FLOW_ALERT, device_class_override="moisture"), + GeErdSensor(self, ErdCode.WH_FILTER_DAY_USAGE, device_class_override="water"), + GeErdSensor(self, ErdCode.WH_SOFTENER_ERROR_CODE, icon_override="mdi:alert-circle"), + GeErdBinarySensor(self, ErdCode.WH_SOFTENER_LOW_SALT, icon_on_override="mdi:alert", icon_off_override="mdi:grain"), + GeErdSensor(self, ErdCode.WH_SOFTENER_SHUTOFF_VALVE_STATE, icon_override="mdi:state-machine"), + GeErdSensor(self, ErdCode.WH_SOFTENER_SALT_LIFE_REMAINING, icon_override="mdi:calendar-clock"), + GeErdShutoffPositionSelect(self, ErdCode.WH_SOFTENER_SHUTOFF_VALVE_CONTROL), + ] + entities = base_entities + ws_entities + return entities diff --git a/custom_components/ge_home/entities/__init__.py b/custom_components/ge_home/entities/__init__.py new file mode 100644 index 0000000..c063f8a --- /dev/null +++ b/custom_components/ge_home/entities/__init__.py @@ -0,0 +1,13 @@ +from .common import * +from .dishwasher import * +from .fridge import * +from .oven import * +from .water_filter import * +from .advantium import * +from .ac import * +from .hood import * +from .water_softener import * +from .water_heater import * +from .opal_ice_maker import * +from .ccm import * +from .dehumidifier import * \ No newline at end of file diff --git a/custom_components/ge_home/entities/ac/__init__.py b/custom_components/ge_home/entities/ac/__init__.py new file mode 100644 index 0000000..aefb995 --- /dev/null +++ b/custom_components/ge_home/entities/ac/__init__.py @@ -0,0 +1,4 @@ +from .ge_wac_climate import GeWacClimate +from .ge_sac_climate import GeSacClimate +from .ge_pac_climate import GePacClimate +from .ge_biac_climate import GeBiacClimate diff --git a/custom_components/ge_home/entities/ac/fan_mode_options.py b/custom_components/ge_home/entities/ac/fan_mode_options.py new file mode 100644 index 0000000..c8a50e6 --- /dev/null +++ b/custom_components/ge_home/entities/ac/fan_mode_options.py @@ -0,0 +1,45 @@ +import logging +from typing import Any, List, Optional + +from gehomesdk import ErdAcFanSetting +from ..common import OptionsConverter + +_LOGGER = logging.getLogger(__name__) + +class AcFanModeOptionsConverter(OptionsConverter): + def __init__(self, default_option: ErdAcFanSetting = ErdAcFanSetting.AUTO): + self._default = default_option + + @property + def options(self) -> List[str]: + return [i.stringify() for i in [ErdAcFanSetting.AUTO, ErdAcFanSetting.LOW, ErdAcFanSetting.MED, ErdAcFanSetting.HIGH]] + + def from_option_string(self, value: str) -> Any: + try: + return ErdAcFanSetting[value.upper().replace(" ","_")] + except: + _LOGGER.warning(f"Could not set fan mode to {value}") + return self._default + + def to_option_string(self, value: Any) -> Optional[str]: + try: + return { + ErdAcFanSetting.AUTO: ErdAcFanSetting.AUTO, + ErdAcFanSetting.LOW: ErdAcFanSetting.LOW, + ErdAcFanSetting.LOW_AUTO: ErdAcFanSetting.AUTO, + ErdAcFanSetting.MED: ErdAcFanSetting.MED, + ErdAcFanSetting.MED_AUTO: ErdAcFanSetting.AUTO, + ErdAcFanSetting.HIGH: ErdAcFanSetting.HIGH, + ErdAcFanSetting.HIGH_AUTO: ErdAcFanSetting.HIGH + }.get(value).stringify() + except: + pass + return self._default.stringify() + +class AcFanOnlyFanModeOptionsConverter(AcFanModeOptionsConverter): + def __init__(self): + super().__init__(ErdAcFanSetting.LOW) + + @property + def options(self) -> List[str]: + return [i.stringify() for i in [ErdAcFanSetting.LOW, ErdAcFanSetting.MED, ErdAcFanSetting.HIGH]] diff --git a/custom_components/ge_home/entities/ac/ge_biac_climate.py b/custom_components/ge_home/entities/ac/ge_biac_climate.py new file mode 100644 index 0000000..db25033 --- /dev/null +++ b/custom_components/ge_home/entities/ac/ge_biac_climate.py @@ -0,0 +1,41 @@ +import logging +from typing import Any, List, Optional + +from homeassistant.components.climate import HVACMode +from gehomesdk import ErdAcOperationMode +from ...devices import ApplianceApi +from ..common import GeClimate, OptionsConverter +from .fan_mode_options import AcFanModeOptionsConverter, AcFanOnlyFanModeOptionsConverter + +_LOGGER = logging.getLogger(__name__) + +class BiacHvacModeOptionsConverter(OptionsConverter): + @property + def options(self) -> List[str]: + return [HVACMode.AUTO, HVACMode.COOL, HVACMode.FAN_ONLY] + def from_option_string(self, value: str) -> Any: + try: + return { + HVACMode.AUTO: ErdAcOperationMode.ENERGY_SAVER, + HVACMode.COOL: ErdAcOperationMode.COOL, + HVACMode.FAN_ONLY: ErdAcOperationMode.FAN_ONLY + }.get(value) + except: + _LOGGER.warning(f"Could not set HVAC mode to {value.upper()}") + return ErdAcOperationMode.COOL + def to_option_string(self, value: Any) -> Optional[str]: + try: + return { + ErdAcOperationMode.ENERGY_SAVER: HVACMode.AUTO, + ErdAcOperationMode.AUTO: HVACMode.AUTO, + ErdAcOperationMode.COOL: HVACMode.COOL, + ErdAcOperationMode.FAN_ONLY: HVACMode.FAN_ONLY + }.get(value) + except: + _LOGGER.warning(f"Could not determine operation mode mapping for {value}") + return HVACMode.COOL + +class GeBiacClimate(GeClimate): + """Class for Built-In AC units""" + def __init__(self, api: ApplianceApi): + super().__init__(api, BiacHvacModeOptionsConverter(), AcFanModeOptionsConverter(), AcFanOnlyFanModeOptionsConverter()) diff --git a/custom_components/ge_home/entities/ac/ge_pac_climate.py b/custom_components/ge_home/entities/ac/ge_pac_climate.py new file mode 100644 index 0000000..42a7a98 --- /dev/null +++ b/custom_components/ge_home/entities/ac/ge_pac_climate.py @@ -0,0 +1,71 @@ +import logging +from typing import Any, List, Optional + +from homeassistant.components.climate import HVACMode +from gehomesdk import ErdCode, ErdAcOperationMode, ErdSacAvailableModes, ErdSacTargetTemperatureRange +from ...devices import ApplianceApi +from ..common import GeClimate, OptionsConverter +from .fan_mode_options import AcFanOnlyFanModeOptionsConverter + +_LOGGER = logging.getLogger(__name__) + +class PacHvacModeOptionsConverter(OptionsConverter): + def __init__(self, available_modes: ErdSacAvailableModes): + self._available_modes = available_modes + + @property + def options(self) -> List[str]: + modes = [HVACMode.COOL, HVACMode.FAN_ONLY] + if self._available_modes and self._available_modes.has_heat: + modes.append(HVACMode.HEAT) + if self._available_modes and self._available_modes.has_dry: + modes.append(HVACMode.DRY) + return modes + def from_option_string(self, value: str) -> Any: + try: + return { + HVACMode.COOL: ErdAcOperationMode.COOL, + HVACMode.HEAT: ErdAcOperationMode.HEAT, + HVACMode.FAN_ONLY: ErdAcOperationMode.FAN_ONLY, + HVACMode.DRY: ErdAcOperationMode.DRY + }.get(value) + except: + _LOGGER.warning(f"Could not set HVAC mode to {value.upper()}") + return ErdAcOperationMode.COOL + def to_option_string(self, value: Any) -> Optional[str]: + try: + return { + ErdAcOperationMode.COOL: HVACMode.COOL, + ErdAcOperationMode.HEAT: HVACMode.HEAT, + ErdAcOperationMode.DRY: HVACMode.DRY, + ErdAcOperationMode.FAN_ONLY: HVACMode.FAN_ONLY + }.get(value) + except: + _LOGGER.warning(f"Could not determine operation mode mapping for {value}") + return HVACMode.COOL + +class GePacClimate(GeClimate): + """Class for Portable AC units""" + def __init__(self, api: ApplianceApi): + #initialize the climate control + super().__init__(api, None, AcFanOnlyFanModeOptionsConverter(), AcFanOnlyFanModeOptionsConverter()) + + #get a couple ERDs that shouldn't change if available + self._modes: ErdSacAvailableModes = self.api.try_get_erd_value(ErdCode.SAC_AVAILABLE_MODES) + self._temp_range: ErdSacTargetTemperatureRange = self.api.try_get_erd_value(ErdCode.SAC_TARGET_TEMPERATURE_RANGE) + #construct the converter based on the available modes + self._hvac_mode_converter = PacHvacModeOptionsConverter(self._modes) + + @property + def min_temp(self) -> float: + temp = 64 + if self._temp_range: + temp = self._temp_range.min + return self._convert_temp(temp) + + @property + def max_temp(self) -> float: + temp = 86 + if self._temp_range: + temp = self._temp_range.max + return self._convert_temp(temp) \ No newline at end of file diff --git a/custom_components/ge_home/entities/ac/ge_sac_climate.py b/custom_components/ge_home/entities/ac/ge_sac_climate.py new file mode 100644 index 0000000..5b239c7 --- /dev/null +++ b/custom_components/ge_home/entities/ac/ge_sac_climate.py @@ -0,0 +1,75 @@ +import logging +from typing import Any, List, Optional + +from homeassistant.components.climate import HVACMode +from gehomesdk import ErdCode, ErdAcOperationMode, ErdSacAvailableModes, ErdSacTargetTemperatureRange +from ...devices import ApplianceApi +from ..common import GeClimate, OptionsConverter +from .fan_mode_options import AcFanOnlyFanModeOptionsConverter, AcFanModeOptionsConverter + +_LOGGER = logging.getLogger(__name__) + +class SacHvacModeOptionsConverter(OptionsConverter): + def __init__(self, available_modes: ErdSacAvailableModes): + self._available_modes = available_modes + + @property + def options(self) -> List[str]: + modes = [HVACMode.COOL, HVACMode.FAN_ONLY] + if self._available_modes and self._available_modes.has_heat: + modes.append(HVACMode.HEAT) + modes.append(HVACMode.AUTO) + if self._available_modes and self._available_modes.has_dry: + modes.append(HVACMode.DRY) + return modes + def from_option_string(self, value: str) -> Any: + try: + return { + HVACMode.AUTO: ErdAcOperationMode.AUTO, + HVACMode.COOL: ErdAcOperationMode.COOL, + HVACMode.HEAT: ErdAcOperationMode.HEAT, + HVACMode.FAN_ONLY: ErdAcOperationMode.FAN_ONLY, + HVACMode.DRY: ErdAcOperationMode.DRY + }.get(value) + except: + _LOGGER.warning(f"Could not set HVAC mode to {value.upper()}") + return ErdAcOperationMode.COOL + def to_option_string(self, value: Any) -> Optional[str]: + try: + return { + ErdAcOperationMode.ENERGY_SAVER: HVACMode.AUTO, + ErdAcOperationMode.AUTO: HVACMode.AUTO, + ErdAcOperationMode.COOL: HVACMode.COOL, + ErdAcOperationMode.HEAT: HVACMode.HEAT, + ErdAcOperationMode.DRY: HVACMode.DRY, + ErdAcOperationMode.FAN_ONLY: HVACMode.FAN_ONLY + }.get(value) + except: + _LOGGER.warning(f"Could not determine operation mode mapping for {value}") + return HVACMode.COOL + +class GeSacClimate(GeClimate): + """Class for Split AC units""" + def __init__(self, api: ApplianceApi): + #initialize the climate control + super().__init__(api, None, AcFanModeOptionsConverter(), AcFanOnlyFanModeOptionsConverter()) + + #get a couple ERDs that shouldn't change if available + self._modes: ErdSacAvailableModes = self.api.try_get_erd_value(ErdCode.SAC_AVAILABLE_MODES) + self._temp_range: ErdSacTargetTemperatureRange = self.api.try_get_erd_value(ErdCode.SAC_TARGET_TEMPERATURE_RANGE) + #construct the converter based on the available modes + self._hvac_mode_converter = SacHvacModeOptionsConverter(self._modes) + + @property + def min_temp(self) -> float: + temp = 60 + if self._temp_range: + temp = self._temp_range.min + return self._convert_temp(temp) + + @property + def max_temp(self) -> float: + temp = 86 + if self._temp_range: + temp = self._temp_range.max + return self._convert_temp(temp) \ No newline at end of file diff --git a/custom_components/ge_home/entities/ac/ge_wac_climate.py b/custom_components/ge_home/entities/ac/ge_wac_climate.py new file mode 100644 index 0000000..2754b90 --- /dev/null +++ b/custom_components/ge_home/entities/ac/ge_wac_climate.py @@ -0,0 +1,41 @@ +import logging +from typing import Any, List, Optional + +from homeassistant.components.climate import HVACMode +from gehomesdk import ErdAcOperationMode +from ...devices import ApplianceApi +from ..common import GeClimate, OptionsConverter +from .fan_mode_options import AcFanModeOptionsConverter, AcFanOnlyFanModeOptionsConverter + +_LOGGER = logging.getLogger(__name__) + +class WacHvacModeOptionsConverter(OptionsConverter): + @property + def options(self) -> List[str]: + return [HVACMode.AUTO, HVACMode.COOL, HVACMode.FAN_ONLY] + def from_option_string(self, value: str) -> Any: + try: + return { + HVACMode.AUTO: ErdAcOperationMode.ENERGY_SAVER, + HVACMode.COOL: ErdAcOperationMode.COOL, + HVACMode.FAN_ONLY: ErdAcOperationMode.FAN_ONLY + }.get(value) + except: + _LOGGER.warning(f"Could not set HVAC mode to {value.upper()}") + return ErdAcOperationMode.COOL + def to_option_string(self, value: Any) -> Optional[str]: + try: + return { + ErdAcOperationMode.ENERGY_SAVER: HVACMode.AUTO, + ErdAcOperationMode.AUTO: HVACMode.AUTO, + ErdAcOperationMode.COOL: HVACMode.COOL, + ErdAcOperationMode.FAN_ONLY: HVACMode.FAN_ONLY + }.get(value) + except: + _LOGGER.warning(f"Could not determine operation mode mapping for {value}") + return HVACMode.COOL + +class GeWacClimate(GeClimate): + """Class for Window AC units""" + def __init__(self, api: ApplianceApi): + super().__init__(api, WacHvacModeOptionsConverter(), AcFanModeOptionsConverter(), AcFanOnlyFanModeOptionsConverter()) diff --git a/custom_components/ge_home/entities/advantium/__init__.py b/custom_components/ge_home/entities/advantium/__init__.py new file mode 100644 index 0000000..a4cfe30 --- /dev/null +++ b/custom_components/ge_home/entities/advantium/__init__.py @@ -0,0 +1 @@ +from .ge_advantium import GeAdvantium \ No newline at end of file diff --git a/custom_components/ge_home/entities/advantium/const.py b/custom_components/ge_home/entities/advantium/const.py new file mode 100644 index 0000000..81e6490 --- /dev/null +++ b/custom_components/ge_home/entities/advantium/const.py @@ -0,0 +1,5 @@ +from homeassistant.components.water_heater import WaterHeaterEntityFeature + +SUPPORT_NONE = WaterHeaterEntityFeature(0) +GE_ADVANTIUM_WITH_TEMPERATURE = (WaterHeaterEntityFeature.OPERATION_MODE | WaterHeaterEntityFeature.TARGET_TEMPERATURE) +GE_ADVANTIUM = WaterHeaterEntityFeature.OPERATION_MODE diff --git a/custom_components/ge_home/entities/advantium/ge_advantium.py b/custom_components/ge_home/entities/advantium/ge_advantium.py new file mode 100644 index 0000000..2088d36 --- /dev/null +++ b/custom_components/ge_home/entities/advantium/ge_advantium.py @@ -0,0 +1,283 @@ +"""GE Home Sensor Entities - Advantium""" +import logging +from typing import Any, Dict, List, Mapping, Optional, Set +from random import randrange + +from gehomesdk import ( + ErdCode, + ErdPersonality, + ErdAdvantiumCookStatus, + ErdAdvantiumCookSetting, + AdvantiumOperationMode, + AdvantiumCookSetting, + ErdAdvantiumRemoteCookModeConfig, + ADVANTIUM_OPERATION_MODE_COOK_SETTING_MAPPING +) +from gehomesdk.erd.values.advantium.advantium_enums import CookAction, CookMode + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ATTR_TEMPERATURE +from ...const import DOMAIN +from ...devices import ApplianceApi +from ..common import GeAbstractWaterHeater +from .const import * + +_LOGGER = logging.getLogger(__name__) + +class GeAdvantium(GeAbstractWaterHeater): + """GE Appliance Advantium""" + + icon = "mdi:microwave" + + def __init__(self, api: ApplianceApi): + super().__init__(api) + + @property + def supported_features(self): + if self.remote_enabled: + return GE_ADVANTIUM_WITH_TEMPERATURE if self.can_set_temperature else GE_ADVANTIUM + else: + return SUPPORT_NONE + + @property + def unique_id(self) -> str: + return f"{DOMAIN}_{self.serial_number}" + + @property + def name(self) -> Optional[str]: + return f"{self.serial_number} Advantium" + + @property + def personality(self) -> Optional[ErdPersonality]: + try: + return self.appliance.get_erd_value(ErdCode.PERSONALITY) + except: + return None + + @property + def remote_enabled(self) -> bool: + """Returns whether the oven is remote enabled""" + value = self.appliance.get_erd_value(ErdCode.UPPER_OVEN_REMOTE_ENABLED) + return value == True + + @property + def current_temperature(self) -> Optional[int]: + return self.appliance.get_erd_value(ErdCode.UPPER_OVEN_DISPLAY_TEMPERATURE) + + @property + def current_operation(self) -> Optional[str]: + try: + return self.current_operation_mode.stringify() + except: + return None + + @property + def operation_list(self) -> List[str]: + invalid = [] + if not self._remote_config.broil_enable: + invalid.append(CookMode.BROIL) + if not self._remote_config.convection_bake_enable: + invalid.append(CookMode.CONVECTION_BAKE) + if not self._remote_config.proof_enable: + invalid.append(CookMode.PROOF) + if not self._remote_config.warm_enable: + invalid.append(CookMode.WARM) + + return [ + k.stringify() + for k, v in ADVANTIUM_OPERATION_MODE_COOK_SETTING_MAPPING.items() + if v.cook_mode not in invalid] + + @property + def current_cook_setting(self) -> ErdAdvantiumCookSetting: + """Get the current cook setting.""" + return self.appliance.get_erd_value(ErdCode.ADVANTIUM_COOK_SETTING) + + @property + def current_cook_status(self) -> ErdAdvantiumCookStatus: + """Get the current status.""" + return self.appliance.get_erd_value(ErdCode.ADVANTIUM_COOK_STATUS) + + @property + def current_operation_mode(self) -> AdvantiumOperationMode: + """Gets the current operation mode""" + return self._current_operation_mode + + @property + def current_operation_setting(self) -> Optional[AdvantiumCookSetting]: + if self.current_operation_mode is None: + return None + try: + return ADVANTIUM_OPERATION_MODE_COOK_SETTING_MAPPING[self.current_operation_mode] + except: + _LOGGER.debug(f"Unable to determine operation setting, mode = {self.current_operation_mode}") + return None + + @property + def can_set_temperature(self) -> bool: + """Indicates whether we can set the temperature based on the current mode""" + try: + return self.current_operation_setting.allow_temperature_set + except: + return False + + @property + def target_temperature(self) -> Optional[int]: + """Return the temperature we try to reach.""" + try: + cook_mode = self.current_cook_setting + if cook_mode.target_temperature and cook_mode.target_temperature > 0: + return cook_mode.target_temperature + except: + pass + return None + + @property + def min_temp(self) -> int: + """Return the minimum temperature.""" + min_temp, _ = self.appliance.get_erd_value(ErdCode.OVEN_MODE_MIN_MAX_TEMP) + return min_temp + + @property + def max_temp(self) -> int: + """Return the maximum temperature.""" + _, max_temp = self.appliance.get_erd_value(ErdCode.OVEN_MODE_MIN_MAX_TEMP) + return max_temp + + @property + def extra_state_attributes(self) -> Optional[Mapping[str, Any]]: + data = {} + + cook_time_remaining = self.appliance.get_erd_value(ErdCode.ADVANTIUM_COOK_TIME_REMAINING) + kitchen_timer = self.appliance.get_erd_value(ErdCode.ADVANTIUM_KITCHEN_TIME_REMAINING) + data["unit_type"] = self._stringify(self.personality) + if cook_time_remaining: + data["cook_time_remaining"] = self._stringify(cook_time_remaining) + if kitchen_timer: + data["kitchen_timer"] = self._stringify(kitchen_timer) + return data + + @property + def _remote_config(self) -> ErdAdvantiumRemoteCookModeConfig: + return self.appliance.get_erd_value(ErdCode.ADVANTIUM_REMOTE_COOK_MODE_CONFIG) + + async def async_set_operation_mode(self, operation_mode: str): + """Set the operation mode.""" + + #try to get the mode/setting for the selection + try: + mode = AdvantiumOperationMode(operation_mode) + setting = ADVANTIUM_OPERATION_MODE_COOK_SETTING_MAPPING[mode] + except: + _LOGGER.debug(f"Attempted to set mode to {operation_mode}, unknown.") + return + + #determine the target temp for this mode + target_temp = self._convert_target_temperature(setting.target_temperature_120v_f, setting.target_temperature_240v_f) + + #if we allow temperature to be set in this mode, and already have a temperature, use it + if setting.allow_temperature_set and self.target_temperature: + target_temp = self.target_temperature + + #by default we will start an operation, but handle other actions too + action = CookAction.START + if mode == AdvantiumOperationMode.OFF: + action = CookAction.STOP + elif self.current_cook_setting.cook_action == CookAction.PAUSE: + action = CookAction.RESUME + elif self.current_cook_setting.cook_action in [CookAction.START, CookAction.RESUME]: + action = CookAction.UPDATED + + #construct the new mode based on the existing mode + new_cook_mode = self.current_cook_setting + new_cook_mode.d = randrange(255) + new_cook_mode.target_temperature = target_temp + if(setting.target_power_level != 0): + new_cook_mode.power_level = setting.target_power_level + new_cook_mode.cook_mode = setting.cook_mode + new_cook_mode.cook_action = action + + await self.appliance.async_set_erd_value(ErdCode.ADVANTIUM_COOK_SETTING, new_cook_mode) + + async def async_set_temperature(self, **kwargs): + """Set the cook temperature""" + + target_temp = kwargs.get(ATTR_TEMPERATURE) + if target_temp is None: + return + + #get the current mode/operation + mode = self.current_operation_mode + setting = self.current_operation_setting + + #if we can't figure out the mode/setting, exit + if mode is None or setting is None: + return + + #if we're off or can't set temperature, just exit + if mode == AdvantiumOperationMode.OFF or not setting.allow_temperature_set: + return + + #should only need to update + action = CookAction.UPDATED + + #construct the new mode based on the existing mode + new_cook_mode = self.current_cook_setting + new_cook_mode.d = randrange(255) + new_cook_mode.target_temperature = target_temp + new_cook_mode.cook_action = action + + await self.appliance.async_set_erd_value(ErdCode.ADVANTIUM_COOK_SETTING, new_cook_mode) + + async def _ensure_operation_mode(self): + cook_setting = self.current_cook_setting + cook_mode = cook_setting.cook_mode + + #if we have a current mode + if(self._current_operation_mode is not None): + #and the cook mode is the same as what the appliance says, we'll just leave things alone + #and assume that things are in sync + if ADVANTIUM_OPERATION_MODE_COOK_SETTING_MAPPING[self._current_operation_mode].cook_mode == cook_mode: + return + else: + self._current_operation_mode = None + + #synchronize the operation mode with the device state + if cook_mode == CookMode.MICROWAVE: + #microwave matches on cook mode and power level + if cook_setting.power_level == 3: + self._current_operation_mode = AdvantiumOperationMode.MICROWAVE_PL3 + elif cook_setting.power_level == 5: + self._current_operation_mode = AdvantiumOperationMode.MICROWAVE_PL5 + elif cook_setting.power_level == 7: + self._current_operation_mode = AdvantiumOperationMode.MICROWAVE_PL7 + else: + self._current_operation_mode = AdvantiumOperationMode.MICROWAVE_PL10 + elif cook_mode == CookMode.WARM: + for key, value in ADVANTIUM_OPERATION_MODE_COOK_SETTING_MAPPING.items(): + #warm matches on the mode, warm status, and target temp + if (cook_mode == value.cook_mode and + cook_setting.warm_status == value.warm_status and + cook_setting.target_temperature == self._convert_target_temperature( + value.target_temperature_120v_f, value.target_temperature_240v_f)): + self._current_operation_mode = key + return + + #just pick the first match based on cook mode if we made it here + if self._current_operation_mode is None: + for key, value in ADVANTIUM_OPERATION_MODE_COOK_SETTING_MAPPING.items(): + if cook_mode == value.cook_mode: + self._current_operation_mode = key + return + + async def _convert_target_temperature(self, temp_120v: int, temp_240v: int): + unit_type = self.personality + target_temp_f = temp_240v if unit_type in [ErdPersonality.PERSONALITY_240V_MONOGRAM, ErdPersonality.PERSONALITY_240V_CAFE, ErdPersonality.PERSONALITY_240V_STANDALONE_CAFE] else temp_120v + if self.temperature_unit == SensorDeviceClass.FAHRENHEIT: + return float(target_temp_f) + else: + return (target_temp_f - 32.0) * (5/9) + + async def async_device_update(self, warning: bool) -> None: + await super().async_device_update(warning=warning) + await self._ensure_operation_mode() diff --git a/custom_components/ge_home/entities/ccm/__init__.py b/custom_components/ge_home/entities/ccm/__init__.py new file mode 100644 index 0000000..614d130 --- /dev/null +++ b/custom_components/ge_home/entities/ccm/__init__.py @@ -0,0 +1,5 @@ +from .ge_ccm_pot_not_present_binary_sensor import GeCcmPotNotPresentBinarySensor +from .ge_ccm_brew_strength import GeCcmBrewStrengthSelect +from .ge_ccm_brew_temperature import GeCcmBrewTemperatureNumber +from .ge_ccm_brew_cups import GeCcmBrewCupsNumber +from .ge_ccm_brew_settings import GeCcmBrewSettingsButton \ No newline at end of file diff --git a/custom_components/ge_home/entities/ccm/ge_ccm_brew_cups.py b/custom_components/ge_home/entities/ccm/ge_ccm_brew_cups.py new file mode 100644 index 0000000..5792f12 --- /dev/null +++ b/custom_components/ge_home/entities/ccm/ge_ccm_brew_cups.py @@ -0,0 +1,19 @@ +from gehomesdk import ErdCode +from ...devices import ApplianceApi +from ..common import GeErdNumber +from .ge_ccm_cached_value import GeCcmCachedValue + +class GeCcmBrewCupsNumber(GeErdNumber, GeCcmCachedValue): + def __init__(self, api: ApplianceApi): + GeErdNumber.__init__(self, api = api, erd_code = ErdCode.CCM_BREW_CUPS, min_value=1, max_value=10, mode="box") + GeCcmCachedValue.__init__(self) + + self._set_value = None + + async def async_set_native_value(self, value): + GeCcmCachedValue.set_value(self, value) + self.schedule_update_ha_state() + + @property + def native_value(self): + return self.get_value(device_value = super().native_value) diff --git a/custom_components/ge_home/entities/ccm/ge_ccm_brew_settings.py b/custom_components/ge_home/entities/ccm/ge_ccm_brew_settings.py new file mode 100644 index 0000000..121392b --- /dev/null +++ b/custom_components/ge_home/entities/ccm/ge_ccm_brew_settings.py @@ -0,0 +1,13 @@ +from gehomesdk import ErdCode +from ...devices import ApplianceApi +from ..common import GeErdButton + +class GeCcmBrewSettingsButton(GeErdButton): + def __init__(self, api: ApplianceApi): + super().__init__(api, erd_code=ErdCode.CCM_BREW_SETTINGS) + + async def async_press(self) -> None: + """Handle the button press.""" + + # Forward the call up to the Coffee Maker device to handle + await self.api.start_brewing() \ No newline at end of file diff --git a/custom_components/ge_home/entities/ccm/ge_ccm_brew_strength.py b/custom_components/ge_home/entities/ccm/ge_ccm_brew_strength.py new file mode 100644 index 0000000..a1d2395 --- /dev/null +++ b/custom_components/ge_home/entities/ccm/ge_ccm_brew_strength.py @@ -0,0 +1,47 @@ +import logging +from typing import List, Any, Optional + +from gehomesdk import ErdCode, ErdCcmBrewStrength +from ...devices import ApplianceApi +from ..common import GeErdSelect, OptionsConverter +from .ge_ccm_cached_value import GeCcmCachedValue + +_LOGGER = logging.getLogger(__name__) + +class GeCcmBrewStrengthOptionsConverter(OptionsConverter): + def __init__(self): + self._default = ErdCcmBrewStrength.MEDIUM + + @property + def options(self) -> List[str]: + return [i.stringify() for i in [ErdCcmBrewStrength.LIGHT, ErdCcmBrewStrength.MEDIUM, ErdCcmBrewStrength.BOLD, ErdCcmBrewStrength.GOLD]] + + def from_option_string(self, value: str) -> Any: + try: + return ErdCcmBrewStrength[value.upper()] + except: + _LOGGER.warning(f"Could not set brew strength to {value.upper()}") + return self._default + + def to_option_string(self, value: ErdCcmBrewStrength) -> Optional[str]: + try: + return value.stringify() + except: + return self._default.stringify() + +class GeCcmBrewStrengthSelect(GeErdSelect, GeCcmCachedValue): + def __init__(self, api: ApplianceApi): + GeErdSelect.__init__(self, api = api, erd_code = ErdCode.CCM_BREW_STRENGTH, converter = GeCcmBrewStrengthOptionsConverter()) + GeCcmCachedValue.__init__(self) + + @property + def brew_strength(self) -> ErdCcmBrewStrength: + return self._converter.from_option_string(self.current_option) + + async def async_select_option(self, value): + GeCcmCachedValue.set_value(self, value) + self.schedule_update_ha_state() + + @property + def current_option(self): + return self.get_value(device_value = super().current_option) \ No newline at end of file diff --git a/custom_components/ge_home/entities/ccm/ge_ccm_brew_temperature.py b/custom_components/ge_home/entities/ccm/ge_ccm_brew_temperature.py new file mode 100644 index 0000000..81b13a4 --- /dev/null +++ b/custom_components/ge_home/entities/ccm/ge_ccm_brew_temperature.py @@ -0,0 +1,26 @@ +from gehomesdk import ErdCode +from ...devices import ApplianceApi +from ..common import GeErdNumber +from .ge_ccm_cached_value import GeCcmCachedValue + +DEFAULT_MIN_TEMP = 100 +DEFAULT_MAX_TEMP = 225 + +class GeCcmBrewTemperatureNumber(GeErdNumber, GeCcmCachedValue): + def __init__(self, api: ApplianceApi): + try: + min_temp, max_temp, _ = api.appliance.get_erd_value(ErdCode.CCM_BREW_TEMPERATURE_RANGE) + except: + min_temp = DEFAULT_MIN_TEMP + max_temp = DEFAULT_MAX_TEMP + + GeErdNumber.__init__(self, api = api, erd_code = ErdCode.CCM_BREW_TEMPERATURE, min_value=min_temp, max_value=max_temp, mode="slider") + GeCcmCachedValue.__init__(self) + + async def async_set_native_value(self, value): + GeCcmCachedValue.set_value(self, value) + self.schedule_update_ha_state() + + @property + def native_value(self): + return int(self.get_value(device_value = super().native_value)) diff --git a/custom_components/ge_home/entities/ccm/ge_ccm_cached_value.py b/custom_components/ge_home/entities/ccm/ge_ccm_cached_value.py new file mode 100644 index 0000000..95c2b94 --- /dev/null +++ b/custom_components/ge_home/entities/ccm/ge_ccm_cached_value.py @@ -0,0 +1,20 @@ +class GeCcmCachedValue(): + def __init__(self): + self._set_value = None + self._last_device_value = None + + def get_value(self, device_value): + + # If the last device value is different from the current one, return the device value which overrides the set value + if self._last_device_value != device_value: + self._last_device_value = device_value + self._set_value = None + return device_value + + if self._set_value is not None: + return self._set_value + + return device_value + + def set_value(self, set_value): + self._set_value = set_value \ No newline at end of file diff --git a/custom_components/ge_home/entities/ccm/ge_ccm_pot_not_present_binary_sensor.py b/custom_components/ge_home/entities/ccm/ge_ccm_pot_not_present_binary_sensor.py new file mode 100644 index 0000000..124914a --- /dev/null +++ b/custom_components/ge_home/entities/ccm/ge_ccm_pot_not_present_binary_sensor.py @@ -0,0 +1,8 @@ +from ..common import GeErdBinarySensor + +class GeCcmPotNotPresentBinarySensor(GeErdBinarySensor): + @property + def is_on(self) -> bool: + """Return True if entity is not pot present.""" + return not self._boolify(self.appliance.get_erd_value(self.erd_code)) + diff --git a/custom_components/ge_home/entities/common/__init__.py b/custom_components/ge_home/entities/common/__init__.py new file mode 100644 index 0000000..546ef73 --- /dev/null +++ b/custom_components/ge_home/entities/common/__init__.py @@ -0,0 +1,17 @@ +from .options_converter import OptionsConverter +from .bool_converter import BoolConverter, ErdOnOffBoolConverter +from .ge_entity import GeEntity +from .ge_erd_entity import GeErdEntity +from .ge_erd_binary_sensor import GeErdBinarySensor +from .ge_erd_property_binary_sensor import GeErdPropertyBinarySensor +from .ge_erd_sensor import GeErdSensor +from .ge_erd_light import GeErdLight +from .ge_erd_timer_sensor import GeErdTimerSensor +from .ge_erd_property_sensor import GeErdPropertySensor +from .ge_erd_switch import GeErdSwitch +from .ge_erd_button import GeErdButton +from .ge_erd_number import GeErdNumber +from .ge_water_heater import GeAbstractWaterHeater +from .ge_erd_select import GeErdSelect +from .ge_climate import GeClimate +from .ge_humidifier import GeHumidifier \ No newline at end of file diff --git a/custom_components/ge_home/entities/common/bool_converter.py b/custom_components/ge_home/entities/common/bool_converter.py new file mode 100644 index 0000000..b8dcd14 --- /dev/null +++ b/custom_components/ge_home/entities/common/bool_converter.py @@ -0,0 +1,19 @@ +from typing import Any + +from gehomesdk import ErdOnOff + +class BoolConverter: + def boolify(self, value: Any) -> bool: + return bool(value) + def true_value(self) -> Any: + return True + def false_value(self) -> Any: + return False + +class ErdOnOffBoolConverter(BoolConverter): + def boolify(self, value: ErdOnOff) -> bool: + return value.boolify() + def true_value(self) -> Any: + return ErdOnOff.ON + def false_value(self) -> Any: + return ErdOnOff.OFF \ No newline at end of file diff --git a/custom_components/ge_home/entities/common/ge_climate.py b/custom_components/ge_home/entities/common/ge_climate.py new file mode 100644 index 0000000..58cbbe1 --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_climate.py @@ -0,0 +1,204 @@ +import logging +from typing import List, Optional + +from homeassistant.components.climate import ClimateEntity +from homeassistant.const import ( + ATTR_TEMPERATURE, + UnitOfTemperature, +) +from homeassistant.components.climate import ClimateEntityFeature, HVACMode +from homeassistant.components.water_heater import WaterHeaterEntityFeature +from gehomesdk import ErdCode, ErdCodeType, ErdMeasurementUnits, ErdOnOff +from ...const import DOMAIN +from ...devices import ApplianceApi +from .ge_erd_entity import GeEntity +from .options_converter import OptionsConverter + +_LOGGER = logging.getLogger(__name__) + +#by default, we'll support target temp and fan mode (derived classes can override) +GE_CLIMATE_SUPPORT = ( + ClimateEntityFeature.TARGET_TEMPERATURE | + ClimateEntityFeature.FAN_MODE | + ClimateEntityFeature.TURN_ON | + ClimateEntityFeature.TURN_OFF +) + +class GeClimate(GeEntity, ClimateEntity): + """GE Climate Base Entity (Window AC, Portable AC, etc)""" + def __init__( + self, + api: ApplianceApi, + hvac_mode_converter: OptionsConverter, + fan_mode_converter: OptionsConverter, + fan_only_fan_mode_converter: OptionsConverter = None, + power_status_erd_code: ErdCodeType = ErdCode.AC_POWER_STATUS, + current_temperature_erd_code: ErdCodeType = ErdCode.AC_AMBIENT_TEMPERATURE, + target_temperature_erd_code: ErdCodeType = ErdCode.AC_TARGET_TEMPERATURE, + hvac_mode_erd_code: ErdCodeType = ErdCode.AC_OPERATION_MODE, + fan_mode_erd_code: ErdCodeType = ErdCode.AC_FAN_SETTING + + ): + super().__init__(api) + self._hvac_mode_converter = hvac_mode_converter + self._fan_mode_converter = fan_mode_converter + self._fan_only_fan_mode_converter = (fan_only_fan_mode_converter + if fan_only_fan_mode_converter is not None + else fan_mode_converter + ) + self._power_status_erd_code = api.appliance.translate_erd_code(power_status_erd_code) + self._current_temperature_erd_code = api.appliance.translate_erd_code(current_temperature_erd_code) + self._target_temperature_erd_code = api.appliance.translate_erd_code(target_temperature_erd_code) + self._hvac_mode_erd_code = api.appliance.translate_erd_code(hvac_mode_erd_code) + self._fan_mode_erd_code = api.appliance.translate_erd_code(fan_mode_erd_code) + + @property + def unique_id(self) -> str: + return f"{DOMAIN}_{self.serial_or_mac}_climate" + + @property + def name(self) -> Optional[str]: + return f"{self.serial_or_mac} Climate" + + @property + def power_status_erd_code(self): + return self._power_status_erd_code + + @property + def target_temperature_erd_code(self): + return self._target_temperature_erd_code + + @property + def current_temperature_erd_code(self): + return self._current_temperature_erd_code + + @property + def hvac_mode_erd_code(self): + return self._hvac_mode_erd_code + + @property + def fan_mode_erd_code(self): + return self._fan_mode_erd_code + + @property + def temperature_unit(self): + #appears to always be Fahrenheit internally, hardcode this + return UnitOfTemperature.FAHRENHEIT + #measurement_system = self.appliance.get_erd_value(ErdCode.TEMPERATURE_UNIT) + #if measurement_system == ErdMeasurementUnits.METRIC: + # return UnitOfTemperature.CELSIUS + #return UnitOfTempterature.FAHRENHEIT + + @property + def supported_features(self): + return GE_CLIMATE_SUPPORT + + @property + def is_on(self) -> bool: + return self.appliance.get_erd_value(self.power_status_erd_code) == ErdOnOff.ON + + @property + def target_temperature(self) -> Optional[float]: + measurement_system = self.appliance.get_erd_value(ErdCode.TEMPERATURE_UNIT) + if measurement_system == ErdMeasurementUnits.METRIC: + targ = float(self.appliance.get_erd_value(self.target_temperature_erd_code)) + targ = round( ((targ - 32.0) * (5/9)) / 2 ) * 2 + return (9 * targ) / 5 + 32 + return float(self.appliance.get_erd_value(self.target_temperature_erd_code)) + + @property + def current_temperature(self) -> Optional[float]: + measurement_system = self.appliance.get_erd_value(ErdCode.TEMPERATURE_UNIT) + if measurement_system == ErdMeasurementUnits.METRIC: + current = float(self.appliance.get_erd_value(self.current_temperature_erd_code)) + current = round( (current - 32.0) * (5/9)) + return (9 * current) / 5 + 32 + return float(self.appliance.get_erd_value(self.current_temperature_erd_code)) + + @property + def min_temp(self) -> float: + return self._convert_temp(64) + + @property + def max_temp(self) -> float: + return self._convert_temp(86) + + @property + def hvac_mode(self): + if not self.is_on: + return HVACMode.OFF + + return self._hvac_mode_converter.to_option_string(self.appliance.get_erd_value(self.hvac_mode_erd_code)) + + @property + def hvac_modes(self) -> List[str]: + return [HVACMode.OFF] + self._hvac_mode_converter.options + + @property + def fan_mode(self): + if self.hvac_mode == HVACMode.FAN_ONLY: + return self._fan_only_fan_mode_converter.to_option_string(self.appliance.get_erd_value(self.fan_mode_erd_code)) + return self._fan_mode_converter.to_option_string(self.appliance.get_erd_value(self.fan_mode_erd_code)) + + @property + def fan_modes(self) -> List[str]: + if self.hvac_mode == HVACMode.FAN_ONLY: + return self._fan_only_fan_mode_converter.options + return self._fan_mode_converter.options + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + _LOGGER.debug(f"Setting HVAC mode from {self.hvac_mode} to {hvac_mode}") + if hvac_mode != self.hvac_mode: + if hvac_mode == HVACMode.OFF: + await self.appliance.async_set_erd_value(self.power_status_erd_code, ErdOnOff.OFF) + else: + #if it's not on, turn it on + if not self.is_on: + await self.appliance.async_set_erd_value(self.power_status_erd_code, ErdOnOff.ON) + + #then set the mode + await self.appliance.async_set_erd_value( + self.hvac_mode_erd_code, + self._hvac_mode_converter.from_option_string(hvac_mode) + ) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + _LOGGER.debug(f"Setting Fan mode from {self.fan_mode} to {fan_mode}") + if fan_mode != self.fan_mode: + converter = (self._fan_only_fan_mode_converter + if self.hvac_mode == HVACMode.FAN_ONLY + else self._fan_mode_converter + ) + + await self.appliance.async_set_erd_value( + self.fan_mode_erd_code, + converter.from_option_string(fan_mode) + ) + + async def async_set_temperature(self, **kwargs) -> None: + #get the temperature if available + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + + #convert to int (setting can only handle ints) + temperature = int(temperature) + + _LOGGER.debug(f"Setting temperature from {self.target_temperature} to {temperature}") + if self.target_temperature != temperature: + await self.appliance.async_set_erd_value(self.target_temperature_erd_code, temperature) + + async def async_turn_on(self): + await self.appliance.async_set_erd_value(self.power_status_erd_code, ErdOnOff.ON) + + async def async_turn_off(self): + await self.appliance.async_set_erd_value(self.power_status_erd_code, ErdOnOff.OFF) + + def _convert_temp(self, temperature_f: int): + if self.temperature_unit == UnitOfTemperature.FAHRENHEIT: + return float(temperature_f) + else: + return (temperature_f - 32.0) * (5/9) + + def _get_icon(self) -> Optional[str]: + return "mdi:air-conditioner" diff --git a/custom_components/ge_home/entities/common/ge_entity.py b/custom_components/ge_home/entities/common/ge_entity.py new file mode 100644 index 0000000..6104d20 --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_entity.py @@ -0,0 +1,85 @@ +from datetime import timedelta +from typing import Optional, Dict, Any + +from gehomesdk import GeAppliance +from ...devices import ApplianceApi + +class GeEntity: + """Base class for all GE Entities""" + should_poll = False + + def __init__(self, api: ApplianceApi): + self._api = api + self._added = False + + @property + def unique_id(self) -> str: + raise NotImplementedError + + @property + def api(self) -> ApplianceApi: + return self._api + + @property + def device_info(self) -> Optional[Dict[str, Any]]: + return self.api.device_info + + @property + def serial_number(self): + return self.api.serial_number + + @property + def available(self) -> bool: + return self.api.available + + @property + def appliance(self) -> GeAppliance: + return self.api.appliance + + @property + def mac_addr(self) -> str: + return self.api.mac_addr + + @property + def serial_or_mac(self) -> str: + return self.api.serial_or_mac + + @property + def name(self) -> Optional[str]: + raise NotImplementedError + + @property + def icon(self) -> Optional[str]: + return self._get_icon() + + @property + def device_class(self) -> Optional[str]: + return self._get_device_class() + + @property + def added(self) -> bool: + return self._added + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + self._added = True + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + self._added = False + + def _stringify(self, value: any, **kwargs) -> Optional[str]: + if isinstance(value, timedelta): + return str(value)[:-3] if value else "" + if value is None: + return None + return self.appliance.stringify_erd_value(value, **kwargs) + + def _boolify(self, value: any) -> Optional[bool]: + return self.appliance.boolify_erd_value(value) + + def _get_icon(self) -> Optional[str]: + return None + + def _get_device_class(self) -> Optional[str]: + return None diff --git a/custom_components/ge_home/entities/common/ge_erd_binary_sensor.py b/custom_components/ge_home/entities/common/ge_erd_binary_sensor.py new file mode 100644 index 0000000..55afc01 --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_binary_sensor.py @@ -0,0 +1,38 @@ +from typing import Optional + +from homeassistant.components.binary_sensor import BinarySensorEntity + +from gehomesdk import ErdCode, ErdCodeType, ErdCodeClass +from ...devices import ApplianceApi +from .ge_erd_entity import GeErdEntity + + +class GeErdBinarySensor(GeErdEntity, BinarySensorEntity): + def __init__(self, api: ApplianceApi, erd_code: ErdCodeType, erd_override: str = None, icon_on_override: str = None, icon_off_override: str = None, device_class_override: str = None): + super().__init__(api, erd_code, erd_override=erd_override, icon_override=icon_on_override, device_class_override=device_class_override) + self._icon_on_override = icon_on_override + self._icon_off_override = icon_off_override + + """GE Entity for binary sensors""" + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self._boolify(self.appliance.get_erd_value(self.erd_code)) + + def _get_icon(self): + if self._icon_on_override and self.is_on: + return self._icon_on_override + if self._icon_off_override and not self.is_on: + return self._icon_off_override + + if self._erd_code_class == ErdCodeClass.DOOR or self.device_class == "door": + return "mdi:door-open" if self.is_on else "mdi:door-closed" + + return super()._get_icon() + + def _get_device_class(self) -> Optional[str]: + if self._device_class_override: + return self._device_class_override + if self._erd_code_class == ErdCodeClass.DOOR: + return "door" + return None diff --git a/custom_components/ge_home/entities/common/ge_erd_button.py b/custom_components/ge_home/entities/common/ge_erd_button.py new file mode 100644 index 0000000..ef28295 --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_button.py @@ -0,0 +1,17 @@ +from typing import Optional + +from homeassistant.components.button import ButtonEntity + +from gehomesdk import ErdCodeType +from ...devices import ApplianceApi +from .ge_erd_entity import GeErdEntity + + +class GeErdButton(GeErdEntity, ButtonEntity): + def __init__(self, api: ApplianceApi, erd_code: ErdCodeType, erd_override: str = None): + super().__init__(api, erd_code, erd_override=erd_override) + + """GE Entity for buttons""" + async def async_press(self) -> None: + """Handle the button press.""" + await self.appliance.async_set_erd_value(self.erd_code, True) diff --git a/custom_components/ge_home/entities/common/ge_erd_entity.py b/custom_components/ge_home/entities/common/ge_erd_entity.py new file mode 100644 index 0000000..6763253 --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_entity.py @@ -0,0 +1,153 @@ +from datetime import timedelta +from typing import Optional + +from gehomesdk import ErdCode, ErdCodeType, ErdCodeClass, ErdMeasurementUnits + +from ...const import DOMAIN +from ...devices import ApplianceApi +from .ge_entity import GeEntity + + +class GeErdEntity(GeEntity): + """Parent class for GE entities tied to a specific ERD""" + + def __init__( + self, + api: ApplianceApi, + erd_code: ErdCodeType, + erd_override: str = None, + icon_override: str = None, + device_class_override: str = None, + ): + super().__init__(api) + self._erd_code = api.appliance.translate_erd_code(erd_code) + self._erd_code_class = api.appliance.get_erd_code_class(self._erd_code) + self._erd_override = erd_override + self._icon_override = icon_override + self._device_class_override = device_class_override + + if not self._erd_code_class: + self._erd_code_class = ErdCodeClass.GENERAL + + @property + def erd_code(self) -> ErdCodeType: + return self._erd_code + + @property + def erd_code_class(self) -> ErdCodeClass: + return self._erd_code_class + + @property + def erd_string(self) -> str: + erd_code = self.erd_code + if isinstance(self.erd_code, ErdCode): + return erd_code.name + return erd_code + + @property + def name(self) -> Optional[str]: + erd_string = self.erd_string + + # override the name if specified + if self._erd_override != None: + erd_string = self._erd_override + + erd_title = " ".join(erd_string.split("_")).title() + return f"{self.serial_or_mac} {erd_title}" + + @property + def unique_id(self) -> Optional[str]: + return f"{DOMAIN}_{self.serial_or_mac}_{self.erd_string.lower()}" + + def _stringify(self, value: any, **kwargs) -> Optional[str]: + """Stringify a value""" + # perform special processing before passing over to the default method + if self.erd_code == ErdCode.CLOCK_TIME: + return value.strftime("%H:%M:%S") if value else None + if self.erd_code_class == ErdCodeClass.RAW_TEMPERATURE: + return f"{value}" + if self.erd_code_class == ErdCodeClass.NON_ZERO_TEMPERATURE: + return f"{value}" if value else "" + if self.erd_code_class == ErdCodeClass.TIMER or isinstance(value, timedelta): + return str(value)[:-3] if value else "Off" + if value is None: + return None + return self.appliance.stringify_erd_value(value, **kwargs) + + @property + def _measurement_system(self) -> Optional[ErdMeasurementUnits]: + """ + Get the measurement system this appliance is using. For now, uses the + temperature unit if available, otherwise assumes imperial. + """ + try: + value = self.appliance.get_erd_value(ErdCode.TEMPERATURE_UNIT) + except KeyError: + return ErdMeasurementUnits.Imperial + return value + + def _get_icon(self): + """Select an appropriate icon.""" + + if self._icon_override: + return self._icon_override + if not isinstance(self.erd_code, ErdCode): + return None + if self.erd_code_class == ErdCodeClass.CLOCK: + return "mdi:clock" + if self.erd_code_class == ErdCodeClass.COUNTER: + return "mdi:counter" + if self.erd_code_class == ErdCodeClass.DOOR: + return "mdi:door" + if self.erd_code_class == ErdCodeClass.TIMER: + return "mdi:timer-outline" + if self.erd_code_class == ErdCodeClass.LOCK_CONTROL: + return "mdi:lock-outline" + if self.erd_code_class == ErdCodeClass.SABBATH_CONTROL: + return "mdi:star-david" + if self.erd_code_class == ErdCodeClass.COOLING_CONTROL: + return "mdi:snowflake" + if self.erd_code_class == ErdCodeClass.OVEN_SENSOR: + return "mdi:stove" + if self.erd_code_class == ErdCodeClass.FRIDGE_SENSOR: + return "mdi:fridge-bottom" + if self.erd_code_class == ErdCodeClass.FREEZER_SENSOR: + return "mdi:fridge-top" + if self.erd_code_class == ErdCodeClass.DISPENSER_SENSOR: + return "mdi:cup-water" + if self.erd_code_class == ErdCodeClass.DISHWASHER_SENSOR: + return "mdi:dishwasher" + if self.erd_code_class == ErdCodeClass.WATERFILTER_SENSOR: + return "mdi:water" + if self.erd_code_class == ErdCodeClass.LAUNDRY_SENSOR: + return "mdi:washing-machine" + if self.erd_code_class == ErdCodeClass.LAUNDRY_WASHER_SENSOR: + return "mdi:washing-machine" + if self.erd_code_class == ErdCodeClass.LAUNDRY_DRYER_SENSOR: + return "mdi:tumble-dryer" + if self.erd_code_class == ErdCodeClass.ADVANTIUM_SENSOR: + return "mdi:microwave" + if self.erd_code_class == ErdCodeClass.FLOW_RATE: + return "mdi:water" + if self.erd_code_class == ErdCodeClass.LIQUID_VOLUME: + return "mdi:water" + if self.erd_code_class == ErdCodeClass.AC_SENSOR: + return "mdi:air-conditioner" + if self.erd_code_class == ErdCodeClass.TEMPERATURE_CONTROL: + return "mdi:thermometer" + if self.erd_code_class == ErdCodeClass.FAN: + return "mdi:fan" + if self.erd_code_class == ErdCodeClass.LIGHT: + return "mdi:lightbulb" + if self.erd_code_class == ErdCodeClass.OIM_SENSOR: + return "mdi:snowflake" + if self.erd_code_class == ErdCodeClass.WATERSOFTENER_SENSOR: + return "mdi:water" + if self.erd_code_class == ErdCodeClass.CCM_SENSOR: + return "mdi:coffee-maker" + if self.erd_code_class == ErdCodeClass.HUMIDITY: + return "mdi:water-percent" + if self.erd_code_class == ErdCodeClass.DEHUMIDIFIER_SENSOR: + return "mdi:air-humidifier" + + return None diff --git a/custom_components/ge_home/entities/common/ge_erd_light.py b/custom_components/ge_home/entities/common/ge_erd_light.py new file mode 100644 index 0000000..35b0422 --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_light.py @@ -0,0 +1,64 @@ +import logging + +from gehomesdk import ErdCodeType +from homeassistant.components.light import ( + ColorMode, + ATTR_BRIGHTNESS, + LightEntity +) + +from ...devices import ApplianceApi +from .ge_erd_entity import GeErdEntity + +_LOGGER = logging.getLogger(__name__) + + +def to_ge_level(level): + """Convert the given Home Assistant light level (0-255) to GE (0-100).""" + return int(round((level * 100) / 255)) + +def to_hass_level(level): + """Convert the given GE (0-100) light level to Home Assistant (0-255).""" + return int((level * 255) // 100) + +class GeErdLight(GeErdEntity, LightEntity): + """Lights for ERD codes.""" + + def __init__(self, api: ApplianceApi, erd_code: ErdCodeType, erd_override: str = None, color_mode = ColorMode.BRIGHTNESS): + super().__init__(api, erd_code, erd_override) + self._color_mode = color_mode + + @property + def supported_color_modes(self): + """Flag supported color modes.""" + return ColorMode.BRIGHTNESS + + @property + def color_mode(self): + """Return the color mode of the light.""" + return self._color_mode + + @property + def brightness(self): + """Return the brightness of the light.""" + return to_hass_level(self.appliance.get_erd_value(self.erd_code)) + + async def _set_brightness(self, brightness, **kwargs): + await self.appliance.async_set_erd_value(self.erd_code, to_ge_level(brightness)) + + @property + def is_on(self) -> bool: + """Return True if light is on.""" + return self.appliance.get_erd_value(self.erd_code) > 0 + + async def async_turn_on(self, **kwargs): + """Turn the light on.""" + brightness = kwargs.pop(ATTR_BRIGHTNESS, 255) + + _LOGGER.debug(f"Turning on {self.unique_id}") + await self._set_brightness(brightness, **kwargs) + + async def async_turn_off(self, **kwargs): + """Turn the light off.""" + _LOGGER.debug(f"Turning off {self.unique_id}") + await self._set_brightness(0, **kwargs) diff --git a/custom_components/ge_home/entities/common/ge_erd_number.py b/custom_components/ge_home/entities/common/ge_erd_number.py new file mode 100644 index 0000000..a92c220 --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_number.py @@ -0,0 +1,127 @@ +import logging +from typing import Optional +from gehomesdk.erd.erd_data_type import ErdDataType +from homeassistant.components.number import ( + NumberEntity, + NumberDeviceClass, +) +from homeassistant.const import UnitOfTemperature +from gehomesdk import ErdCodeType, ErdCodeClass +from .ge_erd_entity import GeErdEntity +from ...devices import ApplianceApi + +_LOGGER = logging.getLogger(__name__) + +class GeErdNumber(GeErdEntity, NumberEntity): + """GE Entity for numbers""" + + def __init__( + self, + api: ApplianceApi, + erd_code: ErdCodeType, + erd_override: str = None, + icon_override: str = None, + device_class_override: str = None, + uom_override: str = None, + data_type_override: ErdDataType = None, + min_value: float = 1, + max_value: float = 100, + step_value: float = 1, + mode: str = "auto" + ): + super().__init__(api, erd_code, erd_override, icon_override, device_class_override) + self._uom_override = uom_override + self._data_type_override = data_type_override + self._native_min_value = min_value + self._native_max_value = max_value + self._native_step = step_value + self._mode = mode + + @property + def native_value(self): + try: + value = self.appliance.get_erd_value(self.erd_code) + return self._convert_value_from_device(value) + except KeyError: + return None + + @property + def native_unit_of_measurement(self) -> Optional[str]: + return self._get_uom() + + @property + def _data_type(self) -> ErdDataType: + if self._data_type_override is not None: + return self._data_type_override + + return self.appliance.get_erd_code_data_type(self.erd_code) + + @property + def native_min_value(self) -> float: + return self._convert_value_from_device(self._native_min_value) + + @property + def native_max_value(self) -> float: + return self._convert_value_from_device(self._native_max_value) + + @property + def native_step(self) -> float: + return self._native_step + + @property + def mode(self) -> float: + return self._mode + + def _convert_value_from_device(self, value): + """Convert to expected data type""" + + if self._data_type == ErdDataType.INT: + return int(round(value)) + else: + return value + + def _get_uom(self): + """Select appropriate units""" + + #if we have an override, just use it + if self._uom_override: + return self._uom_override + + if self.device_class == NumberDeviceClass.TEMPERATURE: + #NOTE: it appears that the API only sets temperature in Fahrenheit, + #so we'll hard code this UOM instead of using the device configured + #settings + return UnitOfTemperature.FAHRENHEIT + + return None + + def _get_device_class(self) -> Optional[str]: + if self._device_class_override: + return self._device_class_override + + if self.erd_code_class in [ + ErdCodeClass.RAW_TEMPERATURE, + ErdCodeClass.NON_ZERO_TEMPERATURE, + ]: + return NumberDeviceClass.TEMPERATURE + + return None + + def _get_icon(self): + if self.erd_code_class == ErdCodeClass.DOOR: + if self.state.lower().endswith("open"): + return "mdi:door-open" + if self.state.lower().endswith("closed"): + return "mdi:door-closed" + return super()._get_icon() + + async def async_set_native_value(self, value): + """Sets the ERD value, assumes that the data type is correct""" + + if self._data_type == ErdDataType.INT: + value = int(round(value)) + + try: + await self.appliance.async_set_erd_value(self.erd_code, value) + except: + _LOGGER.warning(f"Could not set {self.name} to {value}") diff --git a/custom_components/ge_home/entities/common/ge_erd_property_binary_sensor.py b/custom_components/ge_home/entities/common/ge_erd_property_binary_sensor.py new file mode 100644 index 0000000..d7504ce --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_property_binary_sensor.py @@ -0,0 +1,32 @@ +from typing import Optional + +import magicattr +from gehomesdk import ErdCodeType +from ...devices import ApplianceApi +from .ge_erd_binary_sensor import GeErdBinarySensor + +class GeErdPropertyBinarySensor(GeErdBinarySensor): + """GE Entity for property binary sensors""" + def __init__(self, api: ApplianceApi, erd_code: ErdCodeType, erd_property: str, erd_override: str = None, icon_on_override: str = None, icon_off_override: str = None, device_class_override: str = None): + super().__init__(api, erd_code, erd_override, icon_on_override, icon_off_override, device_class_override) + self.erd_property = erd_property + self._erd_property_cleansed = erd_property.replace(".","_").replace("[","_").replace("]","_") + + @property + def is_on(self) -> Optional[bool]: + """Return True if entity is on.""" + try: + value = magicattr.get(self.appliance.get_erd_value(self.erd_code), self.erd_property) + except KeyError: + return None + return self._boolify(value) + + @property + def unique_id(self) -> Optional[str]: + return f"{super().unique_id}_{self._erd_property_cleansed}" + + @property + def name(self) -> Optional[str]: + base_string = super().name + property_name = self._erd_property_cleansed.replace("_", " ").title() + return f"{base_string} {property_name}" diff --git a/custom_components/ge_home/entities/common/ge_erd_property_sensor.py b/custom_components/ge_home/entities/common/ge_erd_property_sensor.py new file mode 100644 index 0000000..70938d0 --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_property_sensor.py @@ -0,0 +1,50 @@ +from typing import Optional + +import magicattr +from gehomesdk import ErdCode, ErdCodeType, ErdMeasurementUnits, ErdDataType +from ...devices import ApplianceApi +from .ge_erd_sensor import GeErdSensor + + +class GeErdPropertySensor(GeErdSensor): + """GE Entity for sensors""" + def __init__( + self, api: ApplianceApi, erd_code: ErdCodeType, erd_property: str, + erd_override: str = None, icon_override: str = None, device_class_override: str = None, + state_class_override: str = None, uom_override: str = None, data_type_override: ErdDataType = None + ): + super().__init__( + api, erd_code, erd_override=erd_override, + icon_override=icon_override, device_class_override=device_class_override, + state_class_override=state_class_override, + uom_override=uom_override, + data_type_override=data_type_override + ) + self.erd_property = erd_property + self._erd_property_cleansed = erd_property.replace(".","_").replace("[","_").replace("]","_") + + @property + def unique_id(self) -> Optional[str]: + return f"{super().unique_id}_{self._erd_property_cleansed}" + + @property + def name(self) -> Optional[str]: + base_string = super().name + property_name = self._erd_property_cleansed.replace("_", " ").title() + return f"{base_string} {property_name}" + + @property + def native_value(self): + try: + value = magicattr.get(self.appliance.get_erd_value(self.erd_code), self.erd_property) + + # if it's a numeric data type, return it directly + if self._data_type in [ErdDataType.INT, ErdDataType.FLOAT]: + return value + + # otherwise, return a stringified version + # TODO: perhaps enhance so that there's a list of variables available + # for the stringify function to consume... + return self._stringify(value, temp_units=self._temp_units) + except KeyError: + return None \ No newline at end of file diff --git a/custom_components/ge_home/entities/common/ge_erd_select.py b/custom_components/ge_home/entities/common/ge_erd_select.py new file mode 100644 index 0000000..833dea2 --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_select.py @@ -0,0 +1,35 @@ + +import logging +from typing import Any, List, Optional + +from homeassistant.components.select import SelectEntity +from gehomesdk import ErdCodeType + +from ...devices import ApplianceApi +from .ge_erd_entity import GeErdEntity +from .options_converter import OptionsConverter + +_LOGGER = logging.getLogger(__name__) + +class GeErdSelect(GeErdEntity, SelectEntity): + """ERD-based selector entity""" + device_class = "select" + + def __init__(self, api: ApplianceApi, erd_code: ErdCodeType, converter: OptionsConverter, erd_override: str = None, icon_override: str = None, device_class_override: str = None): + super().__init__(api, erd_code, erd_override=erd_override, icon_override=icon_override, device_class_override=device_class_override) + self._converter = converter + + @property + def current_option(self): + return self._converter.to_option_string(self.appliance.get_erd_value(self.erd_code)) + + @property + def options(self) -> List[str]: + "Return a list of options" + return self._converter.options + + async def async_select_option(self, option: str) -> None: + _LOGGER.debug(f"Setting select from {self.current_option} to {option}") + """Change the selected option.""" + if option != self.current_option: + await self.appliance.async_set_erd_value(self.erd_code, self._converter.from_option_string(option)) diff --git a/custom_components/ge_home/entities/common/ge_erd_sensor.py b/custom_components/ge_home/entities/common/ge_erd_sensor.py new file mode 100644 index 0000000..132ccaa --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_sensor.py @@ -0,0 +1,192 @@ +from datetime import timedelta +import logging +from typing import Optional +from gehomesdk.erd.erd_data_type import ErdDataType +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass + +from homeassistant.const import UnitOfTemperature +from gehomesdk import ErdCodeType, ErdCodeClass +from .ge_erd_entity import GeErdEntity +from ...devices import ApplianceApi + +_LOGGER = logging.getLogger(__name__) + +class GeErdSensor(GeErdEntity, SensorEntity): + """GE Entity for sensors""" + + def __init__( + self, + api: ApplianceApi, + erd_code: ErdCodeType, + erd_override: str = None, + icon_override: str = None, + device_class_override: str = None, + state_class_override: str = None, + uom_override: str = None, + data_type_override: ErdDataType = None, + suggested_uom: str = None, + suggested_precision: int = None + ): + super().__init__(api, erd_code, erd_override, icon_override, device_class_override) + self._uom_override = uom_override + self._state_class_override = state_class_override + self._data_type_override = data_type_override + self._suggested_uom = suggested_uom + self._suggested_precision = suggested_precision + + @property + def native_value(self): + try: + value = self.appliance.get_erd_value(self.erd_code) + + # if it's a numeric data type, return it directly + if self._data_type in [ErdDataType.INT, ErdDataType.FLOAT]: + return self._convert_numeric_value_from_device(value) + + if self._data_type == ErdDataType.TIMESPAN: + return self._convert_timespan_value_from_device(value) + + # otherwise, return a stringified version + # TODO: perhaps enhance so that there's a list of variables available + # for the stringify function to consume... + return self._stringify(value, temp_units=self._temp_units) + except KeyError: + return None + + @property + def native_unit_of_measurement(self) -> Optional[str]: + return self._get_uom() + + @property + def suggested_unit_of_measurement(self) -> Optional[str]: + return self._suggested_uom + + @property + def suggested_precision(self) -> Optional[int]: + return self._suggested_precision + + @property + def state_class(self) -> Optional[str]: + return self._get_state_class() + + @property + def _data_type(self) -> ErdDataType: + if self._data_type_override is not None: + return self._data_type_override + + return self.appliance.get_erd_code_data_type(self.erd_code) + + @property + def _temp_units(self) -> Optional[str]: + #based on testing, all API values are in Fahrenheit, so we'll redefine + #this property to be the configured temperature unit and set the native + #unit differently + return self.api.hass.config.units.temperature_unit + + #if self._measurement_system == ErdMeasurementUnits.METRIC: + # return UnitOfTemperature.CELSIUS + #return UnitOfTemperature.FAHRENHEIT + + def _convert_numeric_value_from_device(self, value): + """Convert to expected data type""" + + if self._data_type == ErdDataType.INT: + return int(round(value)) + return value + + def _convert_timespan_value_from_device(self, value): + """Convert to expected data type""" + + if value is None: + return 0 + if not isinstance(value, timedelta): + raise ValueError(f"Expected timedelta, got {type(value)}") + return value.total_seconds() + + def _get_uom(self): + """Select appropriate units""" + + #if we have an override, just use it + if self._uom_override: + return self._uom_override + + if ( + self.erd_code_class + in [ErdCodeClass.RAW_TEMPERATURE, ErdCodeClass.NON_ZERO_TEMPERATURE] + or self.device_class == SensorDeviceClass.TEMPERATURE + ): + #NOTE: it appears that the API only sets temperature in Fahrenheit, + #so we'll hard code this UOM instead of using the device configured + #settings + return UnitOfTemperature.FAHRENHEIT + if ( + self.erd_code_class == ErdCodeClass.BATTERY + or self.device_class == SensorDeviceClass.BATTERY + ): + return "%" + if self.erd_code_class == ErdCodeClass.PERCENTAGE: + return "%" + if self.device_class == SensorDeviceClass.POWER_FACTOR: + return "%" + if self.erd_code_class == ErdCodeClass.HUMIDITY: + return "%" + if self.erd_code_class == ErdCodeClass.FLOW_RATE: + #if self._measurement_system == ErdMeasurementUnits.METRIC: + # return "lpm" + return "gpm" + if self.erd_code_class == ErdCodeClass.LIQUID_VOLUME: + #if self._measurement_system == ErdMeasurementUnits.METRIC: + # return "l" + return "gal" + if self.erd_code_class == ErdCodeClass.TIMER: + return "s" + return None + + def _get_device_class(self) -> Optional[str]: + if self._device_class_override: + return self._device_class_override + if self.erd_code_class in [ + ErdCodeClass.RAW_TEMPERATURE, + ErdCodeClass.NON_ZERO_TEMPERATURE, + ]: + return SensorDeviceClass.TEMPERATURE + if self.erd_code_class == ErdCodeClass.BATTERY: + return SensorDeviceClass.BATTERY + if self.erd_code_class == ErdCodeClass.POWER: + return SensorDeviceClass.POWER + if self.erd_code_class == ErdCodeClass.ENERGY: + return SensorDeviceClass.ENERGY + if self.erd_code_class == ErdCodeClass.HUMIDITY: + return SensorDeviceClass.HUMIDITY + if self.erd_code_class == ErdCodeClass.TIMER: + return SensorDeviceClass.DURATION + + return None + + def _get_state_class(self) -> Optional[str]: + if self._state_class_override: + return self._state_class_override + + if self.device_class in [SensorDeviceClass.TEMPERATURE, SensorDeviceClass.ENERGY]: + return SensorStateClass.MEASUREMENT + if self.erd_code_class in [ErdCodeClass.FLOW_RATE, ErdCodeClass.PERCENTAGE, ErdCodeClass.HUMIDITY]: + return SensorStateClass.MEASUREMENT + if self.erd_code_class in [ErdCodeClass.LIQUID_VOLUME]: + return SensorStateClass.TOTAL_INCREASING + + return None + + def _get_icon(self): + if self.erd_code_class == ErdCodeClass.DOOR: + if self.state.lower().endswith("open"): + return "mdi:door-open" + if self.state.lower().endswith("closed"): + return "mdi:door-closed" + return super()._get_icon() + + async def set_value(self, value): + """Sets the ERD value, assumes that the data type is correct""" + try: + await self.appliance.async_set_erd_value(self.erd_code, value) + except: + _LOGGER.warning(f"Could not set {self.name} to {value}") diff --git a/custom_components/ge_home/entities/common/ge_erd_switch.py b/custom_components/ge_home/entities/common/ge_erd_switch.py new file mode 100644 index 0000000..0fb3703 --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_switch.py @@ -0,0 +1,33 @@ +import logging + +from gehomesdk import ErdCodeType +from homeassistant.components.switch import SwitchEntity + +from ...devices import ApplianceApi +from .ge_erd_binary_sensor import GeErdBinarySensor +from .bool_converter import BoolConverter + +_LOGGER = logging.getLogger(__name__) + +class GeErdSwitch(GeErdBinarySensor, SwitchEntity): + """Switches for boolean ERD codes.""" + device_class = "switch" + + def __init__(self, api: ApplianceApi, erd_code: ErdCodeType, bool_converter: BoolConverter = BoolConverter(), erd_override: str = None, icon_on_override: str = None, icon_off_override: str = None, device_class_override: str = None): + super().__init__(api, erd_code, erd_override, icon_on_override, icon_off_override, device_class_override) + self._converter = bool_converter + + @property + def is_on(self) -> bool: + """Return True if switch is on.""" + return self._converter.boolify(self.appliance.get_erd_value(self.erd_code)) + + async def async_turn_on(self, **kwargs): + """Turn the switch on.""" + _LOGGER.debug(f"Turning on {self.unique_id}") + await self.appliance.async_set_erd_value(self.erd_code, self._converter.true_value()) + + async def async_turn_off(self, **kwargs): + """Turn the switch off.""" + _LOGGER.debug(f"Turning off {self.unique_id}") + await self.appliance.async_set_erd_value(self.erd_code, self._converter.false_value()) diff --git a/custom_components/ge_home/entities/common/ge_erd_timer_sensor.py b/custom_components/ge_home/entities/common/ge_erd_timer_sensor.py new file mode 100644 index 0000000..3f5e905 --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_timer_sensor.py @@ -0,0 +1,30 @@ +import asyncio +from datetime import timedelta +from typing import Optional +import logging +import async_timeout + +from gehomesdk import ErdCode, ErdCodeType, ErdCodeClass, ErdMeasurementUnits + +from .ge_erd_sensor import GeErdSensor +from ...devices import ApplianceApi + + +_LOGGER = logging.getLogger(__name__) + +class GeErdTimerSensor(GeErdSensor): + """GE Entity for timer sensors""" + + async def set_timer(self, duration: timedelta): + try: + await self.appliance.async_set_erd_value(self.erd_code, duration) + except: + _LOGGER.warning("Could not set timer value", exc_info=1) + + async def clear_timer(self): + try: + #There's a stupid issue in that if the timer has already expired, the beeping + #won't turn off... I don't see any way around it though. + await self.appliance.async_set_erd_value(self.erd_code, timedelta(seconds=0)) + except: + _LOGGER.warning("Could not clear timer value", exc_info=1) diff --git a/custom_components/ge_home/entities/common/ge_humidifier.py b/custom_components/ge_home/entities/common/ge_humidifier.py new file mode 100644 index 0000000..e102e3b --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_humidifier.py @@ -0,0 +1,105 @@ +import abc +import logging +from typing import Coroutine, Any, Optional + +from homeassistant.components.humidifier import HumidifierEntity, HumidifierDeviceClass +from homeassistant.components.humidifier.const import HumidifierEntityFeature + +from gehomesdk import ErdCodeType, ErdOnOff + +from ...const import DOMAIN +from ...devices import ApplianceApi +from .ge_entity import GeEntity + +DEFAULT_TARGET_PRECISION = 5 + +_LOGGER = logging.getLogger(__name__) + +class GeHumidifier(GeEntity, HumidifierEntity, metaclass=abc.ABCMeta): + """GE Humidifier Abstract Entity """ + + def __init__( + self, + api: ApplianceApi, + device_class: HumidifierDeviceClass, + power_status_erd_code: ErdCodeType, + target_humidity_erd_code: ErdCodeType, + current_humidity_erd_code: ErdCodeType, + range_min: int, + range_max: int, + target_precision = DEFAULT_TARGET_PRECISION + ): + super().__init__(api) + self._device_class = device_class + self._power_status_erd_code = power_status_erd_code + self._target_humidity_erd_code = target_humidity_erd_code + self._current_humidity_erd_code = current_humidity_erd_code + self._range_min = range_min + self._range_max = range_max + self._target_precision = target_precision + + @property + def unique_id(self) -> str: + return f"{DOMAIN}_{self.serial_or_mac}_{self._device_class}" + + @property + def name(self) -> Optional[str]: + return f"{self.serial_or_mac} {self._device_class.title()}" + + @property + def target_humidity(self) -> int | None: + return int(self.appliance.get_erd_value(self._target_humidity_erd_code)) + + @property + def current_humidity(self) -> int | None: + return int(self.appliance.get_erd_value(self._current_humidity_erd_code)) + + @property + def min_humidity(self) -> int: + return self._range_min + + @property + def max_humidity(self) -> int: + return self._range_max + + @property + def supported_features(self) -> HumidifierEntityFeature: + return HumidifierEntityFeature(HumidifierEntityFeature.MODES) + + @property + def is_on(self) -> bool: + return self.appliance.get_erd_value(self._power_status_erd_code) == ErdOnOff.ON + + @property + def device_class(self): + return self._device_class + + async def async_set_humidity(self, humidity: int) -> Coroutine[Any, Any, None]: + # round to target precision + target = round(humidity / self._target_precision) * self._target_precision + + # if it's the same, just exit + if self.target_humidity == target: + return + + _LOGGER.debug(f"Setting Target Humidity from {self.target_humidity} to {target}") + + # make sure we're on + if not self.is_on: + await self.async_turn_on() + + # set the target humidity + await self.appliance.async_set_erd_value( + self._target_humidity_erd_code, + target, + ) + + async def async_turn_on(self): + await self.appliance.async_set_erd_value( + self._power_status_erd_code, ErdOnOff.ON + ) + + async def async_turn_off(self): + await self.appliance.async_set_erd_value( + self._power_status_erd_code, ErdOnOff.OFF + ) \ No newline at end of file diff --git a/custom_components/ge_home/entities/common/ge_water_heater.py b/custom_components/ge_home/entities/common/ge_water_heater.py new file mode 100644 index 0000000..88b376a --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_water_heater.py @@ -0,0 +1,42 @@ +import abc +import logging +from typing import Any, Dict, List, Optional + +from homeassistant.components.water_heater import WaterHeaterEntity +from homeassistant.const import UnitOfTemperature +from gehomesdk import ErdCode, ErdMeasurementUnits +from ...const import DOMAIN +from .ge_erd_entity import GeEntity + +_LOGGER = logging.getLogger(__name__) + +class GeAbstractWaterHeater(GeEntity, WaterHeaterEntity, metaclass=abc.ABCMeta): + """Mock temperature/operation mode supporting device as a water heater""" + + @property + def heater_type(self) -> str: + raise NotImplementedError + + @property + def operation_list(self) -> List[str]: + raise NotImplementedError + + @property + def unique_id(self) -> str: + return f"{DOMAIN}_{self.serial_or_mac}_{self.heater_type}" + + @property + def name(self) -> Optional[str]: + return f"{self.serial_or_mac} {self.heater_type.title()}" + + @property + def temperature_unit(self): + #It appears that the GE API is alwasy Fehrenheit + #measurement_system = self.appliance.get_erd_value(ErdCode.TEMPERATURE_UNIT) + #if measurement_system == ErdMeasurementUnits.METRIC: + # return UnitOfTemperature.CELSIUS + return UnitOfTemperature.FAHRENHEIT + + @property + def supported_features(self): + raise NotImplementedError diff --git a/custom_components/ge_home/entities/common/options_converter.py b/custom_components/ge_home/entities/common/options_converter.py new file mode 100644 index 0000000..5759f46 --- /dev/null +++ b/custom_components/ge_home/entities/common/options_converter.py @@ -0,0 +1,10 @@ +from typing import Any, List, Optional + +class OptionsConverter: + @property + def options(self) -> List[str]: + return [] + def from_option_string(self, value: str) -> Any: + return value + def to_option_string(self, value: Any) -> Optional[str]: + return str(value) diff --git a/custom_components/ge_home/entities/dehumidifier/__init__.py b/custom_components/ge_home/entities/dehumidifier/__init__.py new file mode 100644 index 0000000..f68fa50 --- /dev/null +++ b/custom_components/ge_home/entities/dehumidifier/__init__.py @@ -0,0 +1,2 @@ +from .dehumidifier import GeDehumidifier +from .dehumidifier_fan_speed_sensor import GeDehumidifierFanSpeedSensor \ No newline at end of file diff --git a/custom_components/ge_home/entities/dehumidifier/const.py b/custom_components/ge_home/entities/dehumidifier/const.py new file mode 100644 index 0000000..1e5a8b3 --- /dev/null +++ b/custom_components/ge_home/entities/dehumidifier/const.py @@ -0,0 +1,3 @@ +SMART_DRY = "Smart Dry" +DEFAULT_MIN_HUMIDITY = 35 +DEFAULT_MAX_HUMIDITY = 80 \ No newline at end of file diff --git a/custom_components/ge_home/entities/dehumidifier/dehumidifier.py b/custom_components/ge_home/entities/dehumidifier/dehumidifier.py new file mode 100644 index 0000000..1ed2755 --- /dev/null +++ b/custom_components/ge_home/entities/dehumidifier/dehumidifier.py @@ -0,0 +1,74 @@ +"""GE Home Dehumidifier""" +import logging + +from homeassistant.components.humidifier import HumidifierDeviceClass +from homeassistant.components.humidifier.const import HumidifierEntityFeature + +from gehomesdk import ErdCode, DehumidifierTargetRange + +from ...devices import ApplianceApi +from ..common import GeHumidifier +from .const import * +from .dehumidifier_fan_options import DehumidifierFanSettingOptionsConverter + +_LOGGER = logging.getLogger(__name__) + +class GeDehumidifier(GeHumidifier): + """GE Dehumidifier""" + + icon = "mdi:air-humidifier" + + def __init__(self, api: ApplianceApi): + + #try to get the range + range: DehumidifierTargetRange = api.try_get_erd_value(ErdCode.DHUM_TARGET_HUMIDITY_RANGE) + low = DEFAULT_MIN_HUMIDITY if range is None else range.min_humidity + high = DEFAULT_MAX_HUMIDITY if range is None else range.max_humidity + + #try to get the fan mode and determine feature + mode = api.try_get_erd_value(ErdCode.AC_FAN_SETTING) + self._has_fan = mode is not None + self._mode_converter = DehumidifierFanSettingOptionsConverter() + + #initialize the dehumidifier + super().__init__(api, + HumidifierDeviceClass.DEHUMIDIFIER, + ErdCode.AC_POWER_STATUS, + ErdCode.DHUM_TARGET_HUMIDITY, + ErdCode.DHUM_CURRENT_HUMIDITY, + low, + high + ) + + @property + def supported_features(self) -> HumidifierEntityFeature: + if self._has_fan: + return HumidifierEntityFeature(HumidifierEntityFeature.MODES) + else: + return HumidifierEntityFeature(0) + + @property + def mode(self) -> str | None: + if not self._has_fan: + raise NotImplementedError() + + return self._mode_converter.to_option_string( + self.appliance.get_erd_value(ErdCode.AC_FAN_SETTING) + ) + + @property + def available_modes(self) -> list[str] | None: + if not self._has_fan: + raise NotImplementedError() + + return self._mode_converter.options + + async def async_set_mode(self, mode: str) -> None: + if not self._has_fan: + raise NotImplementedError() + + """Change the selected mode.""" + _LOGGER.debug(f"Setting mode from {self.mode} to {mode}") + + new_state = self._mode_converter.from_option_string(mode) + await self.appliance.async_set_erd_value(ErdCode.AC_FAN_SETTING, new_state) diff --git a/custom_components/ge_home/entities/dehumidifier/dehumidifier_fan_options.py b/custom_components/ge_home/entities/dehumidifier/dehumidifier_fan_options.py new file mode 100644 index 0000000..6ef918d --- /dev/null +++ b/custom_components/ge_home/entities/dehumidifier/dehumidifier_fan_options.py @@ -0,0 +1,29 @@ +import logging +from typing import List, Any, Optional + +from gehomesdk import ErdAcFanSetting +from ..common import OptionsConverter +from .const import SMART_DRY + +_LOGGER = logging.getLogger(__name__) + +class DehumidifierFanSettingOptionsConverter(OptionsConverter): + @property + def options(self) -> List[str]: + return [SMART_DRY] + [i.stringify() for i in [ErdAcFanSetting.LOW, ErdAcFanSetting.MED, ErdAcFanSetting.HIGH]] + + def from_option_string(self, value: str) -> Any: + try: + if value == SMART_DRY: + return ErdAcFanSetting.DEFAULT + return ErdAcFanSetting[value.upper()] + except: + _LOGGER.warning(f"Could not set fan setting to {value.upper()}") + return ErdAcFanSetting.DEFAULT + def to_option_string(self, value: ErdAcFanSetting) -> Optional[str]: + try: + if value is not None: + return SMART_DRY if value == ErdAcFanSetting.DEFAULT else value.stringify() + except: + pass + return SMART_DRY diff --git a/custom_components/ge_home/entities/dehumidifier/dehumidifier_fan_speed_sensor.py b/custom_components/ge_home/entities/dehumidifier/dehumidifier_fan_speed_sensor.py new file mode 100644 index 0000000..217f1e4 --- /dev/null +++ b/custom_components/ge_home/entities/dehumidifier/dehumidifier_fan_speed_sensor.py @@ -0,0 +1,38 @@ +from ...devices import ApplianceApi +from ..common import GeErdSensor +from .dehumidifier_fan_options import DehumidifierFanSettingOptionsConverter +from gehomesdk import ErdCodeType, ErdCodeClass, ErdDataType, ErdAcFanSetting + +class GeDehumidifierFanSpeedSensor(GeErdSensor): + def __init__( + self, + api: ApplianceApi, + erd_code: ErdCodeType, + erd_override: str = None, + icon_override: str = None, + device_class_override: str = None, + state_class_override: str = None, + uom_override: str = None, + data_type_override: ErdDataType = None + ): + + super().__init__( + api, + erd_code, + erd_override, + icon_override, + device_class_override, + state_class_override, + uom_override, + data_type_override + ) + + self._converter = DehumidifierFanSettingOptionsConverter() + + @property + def native_value(self): + try: + value: ErdAcFanSetting = self.appliance.get_erd_value(self.erd_code) + return self._converter.to_option_string(value) + except KeyError: + return None diff --git a/custom_components/ge_home/entities/dishwasher/__init__.py b/custom_components/ge_home/entities/dishwasher/__init__.py new file mode 100644 index 0000000..bef929d --- /dev/null +++ b/custom_components/ge_home/entities/dishwasher/__init__.py @@ -0,0 +1 @@ +from .ge_dishwasher_control_locked_switch import GeDishwasherControlLockedSwitch \ No newline at end of file diff --git a/custom_components/ge_home/entities/dishwasher/ge_dishwasher_control_locked_switch.py b/custom_components/ge_home/entities/dishwasher/ge_dishwasher_control_locked_switch.py new file mode 100644 index 0000000..55923d8 --- /dev/null +++ b/custom_components/ge_home/entities/dishwasher/ge_dishwasher_control_locked_switch.py @@ -0,0 +1,12 @@ +from gehomesdk import ErdCode, ErdOperatingMode + +from ..common import GeErdSwitch + +# TODO: This is actually controlled through the 0x3007 ERD value (SOUND). +# The conversions are a pain in the butt, so this will be left for later. +class GeDishwasherControlLockedSwitch(GeErdSwitch): + @property + def is_on(self) -> bool: + mode: ErdOperatingMode = self.appliance.get_erd_value(ErdCode.OPERATING_MODE) + return mode == ErdOperatingMode.CONTROL_LOCKED + diff --git a/custom_components/ge_home/entities/fridge/__init__.py b/custom_components/ge_home/entities/fridge/__init__.py new file mode 100644 index 0000000..2d14761 --- /dev/null +++ b/custom_components/ge_home/entities/fridge/__init__.py @@ -0,0 +1,5 @@ +from .ge_fridge import GeFridge +from .ge_freezer import GeFreezer +from .ge_dispenser import GeDispenser +from .convertable_drawer_mode_options import ConvertableDrawerModeOptionsConverter +from .ge_fridge_ice_control_switch import GeFridgeIceControlSwitch \ No newline at end of file diff --git a/custom_components/ge_home/entities/fridge/const.py b/custom_components/ge_home/entities/fridge/const.py new file mode 100644 index 0000000..ac71406 --- /dev/null +++ b/custom_components/ge_home/entities/fridge/const.py @@ -0,0 +1,16 @@ +from homeassistant.components.water_heater import WaterHeaterEntityFeature + +ATTR_DOOR_STATUS = "door_status" +GE_FRIDGE_SUPPORT = (WaterHeaterEntityFeature.OPERATION_MODE | WaterHeaterEntityFeature.TARGET_TEMPERATURE) + +HEATER_TYPE_FRIDGE = "fridge" +HEATER_TYPE_FREEZER = "freezer" +HEATER_TYPE_DISPENSER = "dispenser" + +# Fridge/Freezer +OP_MODE_OFF = "Off" +OP_MODE_K_CUP = "K-Cup Brewing" +OP_MODE_NORMAL = "Normal" +OP_MODE_SABBATH = "Sabbath Mode" +OP_MODE_TURBO_COOL = "Turbo Cool" +OP_MODE_TURBO_FREEZE = "Turbo Freeze" diff --git a/custom_components/ge_home/entities/fridge/convertable_drawer_mode_options.py b/custom_components/ge_home/entities/fridge/convertable_drawer_mode_options.py new file mode 100644 index 0000000..fbd5cba --- /dev/null +++ b/custom_components/ge_home/entities/fridge/convertable_drawer_mode_options.py @@ -0,0 +1,56 @@ +import logging +from typing import List, Any, Optional + +from gehomesdk import ErdConvertableDrawerMode +from homeassistant.const import UnitOfTemperature +from homeassistant.util.unit_system import UnitSystem, UnitOfTemperature +from ..common import OptionsConverter + +_LOGGER = logging.getLogger(__name__) + +_TEMP_MAP = { + ErdConvertableDrawerMode.MEAT: 29, + ErdConvertableDrawerMode.BEVERAGE: 33, + ErdConvertableDrawerMode.SNACK: 37, + ErdConvertableDrawerMode.WINE: 42 +} + +class ConvertableDrawerModeOptionsConverter(OptionsConverter): + def __init__(self, units: UnitSystem): + super().__init__() + self._excluded_options = [ + ErdConvertableDrawerMode.UNKNOWN0, + ErdConvertableDrawerMode.UNKNOWN1, + ErdConvertableDrawerMode.NA + ] + self._units = units + + @property + def options(self) -> List[str]: + return [self.to_option_string(i) for i in ErdConvertableDrawerMode if i not in self._excluded_options] + + def from_option_string(self, value: str) -> Any: + try: + v = value.split(" ")[0] + return ErdConvertableDrawerMode[v.upper()] + except: + _LOGGER.warning(f"Could not set drawer mode to {value.upper()}") + return ErdConvertableDrawerMode.NA + def to_option_string(self, value: ErdConvertableDrawerMode) -> Optional[str]: + try: + if value is not None: + v = value.stringify() + t = _TEMP_MAP.get(value, None) + + if t and self._units.temperature_unit == UnitOfTemperature.CELSIUS: + t = self._units.temperature(float(t), UnitOfTemperature.FAHRENHEIT) + t = round(t,1) + + if t: + return f"{v} ({t}{self._units.temperature_unit})" + return v + except: + pass + + return ErdConvertableDrawerMode.NA.stringify() + diff --git a/custom_components/ge_home/entities/fridge/ge_abstract_fridge.py b/custom_components/ge_home/entities/fridge/ge_abstract_fridge.py new file mode 100644 index 0000000..a024ca1 --- /dev/null +++ b/custom_components/ge_home/entities/fridge/ge_abstract_fridge.py @@ -0,0 +1,199 @@ +"""GE Home Sensor Entities - Abstract Fridge""" +import importlib +import sys +import os +import abc +import logging +from typing import Any, Dict, List, Optional + +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.util.unit_conversion import TemperatureConverter +from gehomesdk import ( + ErdCode, + ErdOnOff, + ErdFullNotFull, + FridgeDoorStatus, + FridgeSetPointLimits, + FridgeSetPoints, + FridgeIceBucketStatus, + IceMakerControlStatus +) +from ...const import DOMAIN +from ..common import GeAbstractWaterHeater +from .const import * + +_LOGGER = logging.getLogger(__name__) + +class GeAbstractFridge(GeAbstractWaterHeater): + """Mock a fridge or freezer as a water heater.""" + + # These values are from the Fisher & Paykel RF610AA in imperial units + # They're to be used as hardcoded limits when ErdCode.SETPOINT_LIMITS is unavailable. + temp_limits = {} + temp_limits["fridge_min"] = 32 + temp_limits["fridge_max"] = 46 + temp_limits["freezer_min"] = -6 + temp_limits["freezer_max"] = 7 + + @property + def heater_type(self) -> str: + raise NotImplementedError + + @property + def turbo_erd_code(self) -> str: + raise NotImplementedError + + @property + def turbo_mode(self) -> str: + raise NotImplementedError + + @property + def operation_list(self) -> List[str]: + try: + return [OP_MODE_NORMAL, OP_MODE_SABBATH, self.turbo_mode] + except: + _LOGGER.debug("Turbo mode not supported.") + return [OP_MODE_NORMAL, OP_MODE_SABBATH] + + @property + def unique_id(self) -> str: + return f"{DOMAIN}_{self.serial_number}_{self.heater_type}" + + @property + def name(self) -> Optional[str]: + return f"{self.serial_or_mac} {self.heater_type.title()}" + + @property + def target_temps(self) -> FridgeSetPoints: + """Get the current temperature settings tuple.""" + return self.appliance.get_erd_value(ErdCode.TEMPERATURE_SETTING) + + @property + def target_temperature(self) -> int: + """Return the temperature we try to reach.""" + return getattr(self.target_temps, self.heater_type) + + @property + def current_temperature(self) -> int: + """Return the current temperature.""" + try: + current_temps = self.appliance.get_erd_value(ErdCode.CURRENT_TEMPERATURE) + current_temp = getattr(current_temps, self.heater_type) + if current_temp is None: + _LOGGER.exception(f"{self.name} has None for current_temperature (available: {self.available})!") + return current_temp + except: + _LOGGER.debug("Device doesn't report current temperature.") + return None + + async def async_set_temperature(self, **kwargs): + target_temp = kwargs.get(ATTR_TEMPERATURE) + if target_temp is None: + return + if not self.min_temp <= target_temp <= self.max_temp: + raise ValueError("Tried to set temperature out of device range") + + if self.heater_type == HEATER_TYPE_FRIDGE: + new_temp = FridgeSetPoints(fridge=target_temp, freezer=self.target_temps.freezer) + elif self.heater_type == HEATER_TYPE_FREEZER: + new_temp = FridgeSetPoints(fridge=self.target_temps.fridge, freezer=target_temp) + else: + raise ValueError("Invalid heater_type") + + await self.appliance.async_set_erd_value(ErdCode.TEMPERATURE_SETTING, new_temp) + + @property + def supported_features(self): + return GE_FRIDGE_SUPPORT + + @property + def setpoint_limits(self) -> FridgeSetPointLimits: + return self.appliance.get_erd_value(ErdCode.SETPOINT_LIMITS) + + @property + def min_temp(self): + """Return the minimum temperature if available, otherwise use hardcoded limits.""" + try: + return getattr(self.setpoint_limits, f"{self.heater_type}_min") + except: + _LOGGER.debug("No temperature setpoint limits available. Using hardcoded limits.") + return TemperatureConverter.convert(self.temp_limits[f"{self.heater_type}_min"], UnitOfTemperature.FAHRENHEIT, self.temperature_unit) + + @property + def max_temp(self): + """Return the maximum temperature if available, otherwise use hardcoded limits.""" + try: + return getattr(self.setpoint_limits, f"{self.heater_type}_max") + except: + _LOGGER.debug("No temperature setpoint limits available. Using hardcoded limits.") + return TemperatureConverter.convert(self.temp_limits[f"{self.heater_type}_max"], UnitOfTemperature.FAHRENHEIT, self.temperature_unit) + + @property + def current_operation(self) -> str: + """Get the current operation mode.""" + if self.appliance.get_erd_value(ErdCode.SABBATH_MODE): + return OP_MODE_SABBATH + try: + if self.appliance.get_erd_value(self.turbo_erd_code): + return self.turbo_mode + except: + _LOGGER.debug("Turbo mode not supported.") + return OP_MODE_NORMAL + + async def async_set_sabbath_mode(self, sabbath_on: bool = True): + """Set sabbath mode if it's changed""" + if self.appliance.get_erd_value(ErdCode.SABBATH_MODE) == sabbath_on: + return + await self.appliance.async_set_erd_value(ErdCode.SABBATH_MODE, sabbath_on) + + async def async_set_operation_mode(self, operation_mode): + """Set the operation mode.""" + if operation_mode not in self.operation_list: + raise ValueError("Invalid operation mode") + if operation_mode == self.current_operation: + return + sabbath_mode = operation_mode == OP_MODE_SABBATH + await self.async_set_sabbath_mode(sabbath_mode) + if not sabbath_mode: + await self.appliance.async_set_erd_value(self.turbo_erd_code, operation_mode == self.turbo_mode) + + @property + def door_status(self) -> FridgeDoorStatus: + """Shorthand to get door status.""" + return self.appliance.get_erd_value(ErdCode.DOOR_STATUS) + + @property + def ice_maker_state_attrs(self) -> Dict[str, Any]: + """Get state attributes for the ice maker, if applicable.""" + data = {} + + if self.api.has_erd_code(ErdCode.ICE_MAKER_BUCKET_STATUS): + erd_val: FridgeIceBucketStatus = self.appliance.get_erd_value(ErdCode.ICE_MAKER_BUCKET_STATUS) + ice_bucket_status = getattr(erd_val, f"state_full_{self.heater_type}") + if ice_bucket_status != ErdFullNotFull.NA: + data["ice_bucket"] = self._stringify(ice_bucket_status) + + if self.api.has_erd_code(ErdCode.ICE_MAKER_CONTROL): + erd_val: IceMakerControlStatus = self.appliance.get_erd_value(ErdCode.ICE_MAKER_CONTROL) + ice_control_status = getattr(erd_val, f"status_{self.heater_type}") + if ice_control_status != ErdOnOff.NA: + data["ice_maker"] = self._stringify(ice_control_status) + + return data + + @property + def door_state_attrs(self) -> Dict[str, Any]: + """Get state attributes for the doors.""" + return {} + + @property + def other_state_attrs(self) -> Dict[str, Any]: + """Other state attributes for the entity""" + return {} + + @property + def extra_state_attributes(self) -> Dict[str, Any]: + door_attrs = self.door_state_attrs + ice_maker_attrs = self.ice_maker_state_attrs + other_state_attrs = self.other_state_attrs + return {**door_attrs, **ice_maker_attrs, **other_state_attrs} diff --git a/custom_components/ge_home/entities/fridge/ge_dispenser.py b/custom_components/ge_home/entities/fridge/ge_dispenser.py new file mode 100644 index 0000000..394af9e --- /dev/null +++ b/custom_components/ge_home/entities/fridge/ge_dispenser.py @@ -0,0 +1,126 @@ +"""GE Home Sensor Entities - Dispenser""" + +import logging +from typing import List, Optional, Dict, Any + +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.util.unit_conversion import TemperatureConverter + +from gehomesdk import ( + ErdCode, + ErdHotWaterStatus, + ErdPresent, + ErdPodStatus, + ErdFullNotFull, + HotWaterStatus +) + +from ..common import GeAbstractWaterHeater +from .const import ( + HEATER_TYPE_DISPENSER, + OP_MODE_NORMAL, + OP_MODE_SABBATH, + GE_FRIDGE_SUPPORT +) + +_LOGGER = logging.getLogger(__name__) + +class GeDispenser(GeAbstractWaterHeater): + """Entity for in-fridge dispensers""" + + # These values are from FridgeHotWaterFragment.smali in the android app (in imperial units) + # However, the k-cup temperature max appears to be 190. Since there doesn't seem to be any + # Difference between normal heating and k-cup heating based on what I see in the app, + # we will just set the max temp to 190 instead of the 185 + _min_temp = 90 + _max_temp = 190 #185 + icon = "mdi:cup-water" + heater_type = HEATER_TYPE_DISPENSER + + @property + def hot_water_status(self) -> HotWaterStatus: + """Access the main status value conveniently.""" + return self.appliance.get_erd_value(ErdCode.HOT_WATER_STATUS) + + @property + def supports_k_cups(self) -> bool: + """Return True if the device supports k-cup brewing.""" + status = self.hot_water_status + return status.pod_status != ErdPodStatus.NA and status.brew_module != ErdPresent.NA + + @property + def operation_list(self) -> List[str]: + """Supported Operations List""" + ops_list = [OP_MODE_NORMAL, OP_MODE_SABBATH] + return ops_list + + async def async_set_temperature(self, **kwargs): + target_temp = kwargs.get(ATTR_TEMPERATURE) + if target_temp is None: + return + if not self.min_temp <= target_temp <= self.max_temp: + raise ValueError("Tried to set temperature out of device range") + + await self.appliance.async_set_erd_value(ErdCode.HOT_WATER_SET_TEMP, target_temp) + + async def async_set_sabbath_mode(self, sabbath_on: bool = True): + """Set sabbath mode if it's changed""" + if self.appliance.get_erd_value(ErdCode.SABBATH_MODE) == sabbath_on: + return + await self.appliance.async_set_erd_value(ErdCode.SABBATH_MODE, sabbath_on) + + async def async_set_operation_mode(self, operation_mode): + """Set the operation mode.""" + if operation_mode not in self.operation_list: + raise ValueError("Invalid operation mode") + if operation_mode == self.current_operation: + return + sabbath_mode = operation_mode == OP_MODE_SABBATH + await self.async_set_sabbath_mode(sabbath_mode) + + @property + def supported_features(self): + return GE_FRIDGE_SUPPORT + + @property + def current_operation(self) -> str: + """Get the current operation mode.""" + if self.appliance.get_erd_value(ErdCode.SABBATH_MODE): + return OP_MODE_SABBATH + return OP_MODE_NORMAL + + @property + def current_temperature(self) -> Optional[int]: + """Return the current temperature.""" + return self.hot_water_status.current_temp + + @property + def target_temperature(self) -> Optional[int]: + """Return the target temperature.""" + return self.appliance.get_erd_value(ErdCode.HOT_WATER_SET_TEMP) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return TemperatureConverter.convert(self._min_temp, UnitOfTemperature.FAHRENHEIT, self.temperature_unit) + + @property + def max_temp(self): + """Return the maximum temperature.""" + return TemperatureConverter.convert(self._max_temp, UnitOfTemperature.FAHRENHEIT, self.temperature_unit) + + @property + def extra_state_attributes(self) -> Dict[str, Any]: + data = {} + + data["target_temperature"] = self.target_temperature + if self.hot_water_status.status in [ErdHotWaterStatus.FAULT_LOCKED_OUT, ErdHotWaterStatus.FAULT_NEED_CLEARED]: + data["fault_status"] = self._stringify(self.hot_water_status.status) + if self.supports_k_cups: + data["pod_status"] = self._stringify(self.hot_water_status.pod_status) + if self.hot_water_status.time_until_ready: + data["time_until_ready"] = self._stringify(self.hot_water_status.time_until_ready) + if self.hot_water_status.tank_full != ErdFullNotFull.NA: + data["tank_status"] = self._stringify(self.hot_water_status.tank_full) + + return data diff --git a/custom_components/ge_home/entities/fridge/ge_freezer.py b/custom_components/ge_home/entities/fridge/ge_freezer.py new file mode 100644 index 0000000..005dba9 --- /dev/null +++ b/custom_components/ge_home/entities/fridge/ge_freezer.py @@ -0,0 +1,35 @@ +"""GE Home Sensor Entities - Freezer""" +import logging +from typing import Any, Dict, Optional + +from gehomesdk import ( + ErdCode, + ErdDoorStatus +) + +from .ge_abstract_fridge import ( + ATTR_DOOR_STATUS, + HEATER_TYPE_FREEZER, + OP_MODE_TURBO_FREEZE, + GeAbstractFridge +) + +_LOGGER = logging.getLogger(__name__) + +class GeFreezer(GeAbstractFridge): + """A freezer is basically a fridge.""" + + heater_type = HEATER_TYPE_FREEZER + turbo_erd_code = ErdCode.TURBO_FREEZE_STATUS + turbo_mode = OP_MODE_TURBO_FREEZE + icon = "mdi:fridge-top" + + @property + def door_state_attrs(self) -> Optional[Dict[str, Any]]: + try: + door_status = self.door_status.freezer + if door_status and door_status != ErdDoorStatus.NA: + return {ATTR_DOOR_STATUS: self._stringify(door_status)} + except: + _LOGGER.debug("Device does not report door status.") + return {} diff --git a/custom_components/ge_home/entities/fridge/ge_fridge.py b/custom_components/ge_home/entities/fridge/ge_fridge.py new file mode 100644 index 0000000..e24c3e0 --- /dev/null +++ b/custom_components/ge_home/entities/fridge/ge_fridge.py @@ -0,0 +1,62 @@ +"""GE Home Sensor Entities - Fridge""" +import logging +from typing import Any, Dict + +from gehomesdk import ( + ErdCode, + ErdDoorStatus, + ErdFilterStatus +) + +from .const import * +from .ge_abstract_fridge import ( + ATTR_DOOR_STATUS, + HEATER_TYPE_FRIDGE, + OP_MODE_TURBO_COOL, + GeAbstractFridge +) + +_LOGGER = logging.getLogger(__name__) + +class GeFridge(GeAbstractFridge): + heater_type = HEATER_TYPE_FRIDGE + turbo_erd_code = ErdCode.TURBO_COOL_STATUS + turbo_mode = OP_MODE_TURBO_COOL + icon = "mdi:fridge-bottom" + + @property + def other_state_attrs(self) -> Dict[str, Any]: + if(self.api.has_erd_code(ErdCode.WATER_FILTER_STATUS)): + filter_status: ErdFilterStatus = self.appliance.get_erd_value(ErdCode.WATER_FILTER_STATUS) + if filter_status == ErdFilterStatus.NA: + return {} + return {"water_filter_status": self._stringify(filter_status)} + return {} + + @property + def door_state_attrs(self) -> Dict[str, Any]: + """Get state attributes for the doors.""" + try: + data = {} + door_status = self.door_status + if not door_status: + return {} + door_right = door_status.fridge_right + door_left = door_status.fridge_left + drawer = door_status.drawer + + if door_right and door_right != ErdDoorStatus.NA: + data["right_door"] = door_status.fridge_right.name.title() + if door_left and door_left != ErdDoorStatus.NA: + data["left_door"] = door_status.fridge_left.name.title() + if drawer and drawer != ErdDoorStatus.NA: + data["drawer"] = door_status.drawer.name.title() + + if data: + all_closed = all(v == "Closed" for v in data.values()) + data[ATTR_DOOR_STATUS] = "Closed" if all_closed else "Open" + + return data + except: + _LOGGER.debug("Device does not report door status.") + return {} diff --git a/custom_components/ge_home/entities/fridge/ge_fridge_ice_control_switch.py b/custom_components/ge_home/entities/fridge/ge_fridge_ice_control_switch.py new file mode 100644 index 0000000..f86c59a --- /dev/null +++ b/custom_components/ge_home/entities/fridge/ge_fridge_ice_control_switch.py @@ -0,0 +1,47 @@ +import logging +from gehomesdk import ErdCode, IceMakerControlStatus, ErdOnOff + +from ...devices import ApplianceApi +from ..common import GeErdSwitch, BoolConverter + +_LOGGER = logging.getLogger(__name__) + +class GeFridgeIceControlSwitch(GeErdSwitch): + def __init__(self, api: ApplianceApi, control_type: str): + super().__init__(api, ErdCode.ICE_MAKER_CONTROL, BoolConverter()) + self._control_type = control_type + + @property + def control_status(self) -> IceMakerControlStatus: + return self.appliance.get_erd_value(ErdCode.ICE_MAKER_CONTROL) + + @property + def is_on(self) -> bool: + if self._control_type == "fridge": + return self.control_status.status_fridge == ErdOnOff.ON + else: + return self.control_status.status_freezer == ErdOnOff.ON + + async def async_turn_on(self, **kwargs): + """Turn the switch on.""" + _LOGGER.debug(f"Turning on {self.unique_id}") + + old_status = self.control_status + if self._control_type == "fridge": + new_status = IceMakerControlStatus(ErdOnOff.ON, old_status.status_freezer) + else: + new_status = IceMakerControlStatus(old_status.status_fridge, ErdOnOff.ON) + + await self.appliance.async_set_erd_value(self.erd_code, new_status) + + async def async_turn_off(self, **kwargs): + """Turn the switch off.""" + _LOGGER.debug(f"Turning off {self.unique_id}") + + old_status = self.control_status + if self._control_type == "fridge": + new_status = IceMakerControlStatus(ErdOnOff.OFF, old_status.status_freezer) + else: + new_status = IceMakerControlStatus(old_status.status_fridge, ErdOnOff.OFF) + + await self.appliance.async_set_erd_value(self.erd_code, new_status) diff --git a/custom_components/ge_home/entities/hood/__init__.py b/custom_components/ge_home/entities/hood/__init__.py new file mode 100644 index 0000000..abba26b --- /dev/null +++ b/custom_components/ge_home/entities/hood/__init__.py @@ -0,0 +1,2 @@ +from .ge_hood_fan_speed import GeHoodFanSpeedSelect +from .ge_hood_light_level import GeHoodLightLevelSelect \ No newline at end of file diff --git a/custom_components/ge_home/entities/hood/ge_hood_fan_speed.py b/custom_components/ge_home/entities/hood/ge_hood_fan_speed.py new file mode 100644 index 0000000..0dcfe73 --- /dev/null +++ b/custom_components/ge_home/entities/hood/ge_hood_fan_speed.py @@ -0,0 +1,46 @@ +import logging +from typing import List, Any, Optional + +from gehomesdk import ErdCodeType, ErdHoodFanSpeedAvailability, ErdHoodFanSpeed, ErdCode +from ...devices import ApplianceApi +from ..common import GeErdSelect, OptionsConverter + +_LOGGER = logging.getLogger(__name__) + +class HoodFanSpeedOptionsConverter(OptionsConverter): + def __init__(self, availability: ErdHoodFanSpeedAvailability): + super().__init__() + self.availability = availability + self.excluded_speeds = [] + if not availability.off_available: + self.excluded_speeds.append(ErdHoodFanSpeed.OFF) + if not availability.low_available: + self.excluded_speeds.append(ErdHoodFanSpeed.LOW) + if not availability.med_available: + self.excluded_speeds.append(ErdHoodFanSpeed.MEDIUM) + if not availability.high_available: + self.excluded_speeds.append(ErdHoodFanSpeed.HIGH) + if not availability.boost_available: + self.excluded_speeds.append(ErdHoodFanSpeed.BOOST) + + @property + def options(self) -> List[str]: + return [i.stringify() for i in ErdHoodFanSpeed if i not in self.excluded_speeds] + def from_option_string(self, value: str) -> Any: + try: + return ErdHoodFanSpeed[value.upper()] + except: + _LOGGER.warning(f"Could not set hood fan speed to {value.upper()}") + return ErdHoodFanSpeed.OFF + def to_option_string(self, value: ErdHoodFanSpeed) -> Optional[str]: + try: + if value is not None: + return value.stringify() + except: + pass + return ErdHoodFanSpeed.OFF.stringify() + +class GeHoodFanSpeedSelect(GeErdSelect): + def __init__(self, api: ApplianceApi, erd_code: ErdCodeType): + self._availability: ErdHoodFanSpeedAvailability = api.try_get_erd_value(ErdCode.HOOD_FAN_SPEED_AVAILABILITY) + super().__init__(api, erd_code, HoodFanSpeedOptionsConverter(self._availability)) diff --git a/custom_components/ge_home/entities/hood/ge_hood_light_level.py b/custom_components/ge_home/entities/hood/ge_hood_light_level.py new file mode 100644 index 0000000..a44dccd --- /dev/null +++ b/custom_components/ge_home/entities/hood/ge_hood_light_level.py @@ -0,0 +1,42 @@ +import logging +from typing import List, Any, Optional + +from gehomesdk import ErdCodeType, ErdHoodLightLevelAvailability, ErdHoodLightLevel, ErdCode +from ...devices import ApplianceApi +from ..common import GeErdSelect, OptionsConverter + +_LOGGER = logging.getLogger(__name__) + +class HoodLightLevelOptionsConverter(OptionsConverter): + def __init__(self, availability: ErdHoodLightLevelAvailability): + super().__init__() + self.availability = availability + self.excluded_levels = [] + if not availability.off_available: + self.excluded_levels.append(ErdHoodLightLevel.OFF) + if not availability.dim_available: + self.excluded_levels.append(ErdHoodLightLevel.DIM) + if not availability.high_available: + self.excluded_levels.append(ErdHoodLightLevel.HIGH) + + @property + def options(self) -> List[str]: + return [i.stringify() for i in ErdHoodLightLevel if i not in self.excluded_levels] + def from_option_string(self, value: str) -> Any: + try: + return ErdHoodLightLevel[value.upper()] + except: + _LOGGER.warning(f"Could not set hood light level to {value.upper()}") + return ErdHoodLightLevel.OFF + def to_option_string(self, value: ErdHoodLightLevel) -> Optional[str]: + try: + if value is not None: + return value.stringify() + except: + pass + return ErdHoodLightLevel.OFF.stringify() + +class GeHoodLightLevelSelect(GeErdSelect): + def __init__(self, api: ApplianceApi, erd_code: ErdCodeType): + self._availability: ErdHoodLightLevelAvailability = api.try_get_erd_value(ErdCode.HOOD_LIGHT_LEVEL_AVAILABILITY) + super().__init__(api, erd_code, HoodLightLevelOptionsConverter(self._availability)) diff --git a/custom_components/ge_home/entities/opal_ice_maker/__init__.py b/custom_components/ge_home/entities/opal_ice_maker/__init__.py new file mode 100644 index 0000000..5ec3f31 --- /dev/null +++ b/custom_components/ge_home/entities/opal_ice_maker/__init__.py @@ -0,0 +1 @@ +from .oim_light_level_options import OimLightLevelOptionsConverter \ No newline at end of file diff --git a/custom_components/ge_home/entities/opal_ice_maker/oim_light_level_options.py b/custom_components/ge_home/entities/opal_ice_maker/oim_light_level_options.py new file mode 100644 index 0000000..6d0114d --- /dev/null +++ b/custom_components/ge_home/entities/opal_ice_maker/oim_light_level_options.py @@ -0,0 +1,26 @@ +import logging +from typing import List, Any, Optional + +from gehomesdk import ErdCodeType, ErdOimLightLevel, ErdCode +from ...devices import ApplianceApi +from ..common import GeErdSelect, OptionsConverter + +_LOGGER = logging.getLogger(__name__) + +class OimLightLevelOptionsConverter(OptionsConverter): + @property + def options(self) -> List[str]: + return [i.stringify() for i in ErdOimLightLevel] + def from_option_string(self, value: str) -> Any: + try: + return ErdOimLightLevel[value.upper()] + except: + _LOGGER.warning(f"Could not set hood light level to {value.upper()}") + return ErdOimLightLevel.OFF + def to_option_string(self, value: ErdOimLightLevel) -> Optional[str]: + try: + if value is not None: + return value.stringify() + except: + pass + return ErdOimLightLevel.OFF.stringify() diff --git a/custom_components/ge_home/entities/oven/__init__.py b/custom_components/ge_home/entities/oven/__init__.py new file mode 100644 index 0000000..6ef1066 --- /dev/null +++ b/custom_components/ge_home/entities/oven/__init__.py @@ -0,0 +1,4 @@ +from .ge_oven import GeOven +from .ge_oven_light_level_select import GeOvenLightLevelSelect +from .ge_oven_warming_state_select import GeOvenWarmingStateSelect +from .const import UPPER_OVEN, LOWER_OVEN \ No newline at end of file diff --git a/custom_components/ge_home/entities/oven/const.py b/custom_components/ge_home/entities/oven/const.py new file mode 100644 index 0000000..cb92f1c --- /dev/null +++ b/custom_components/ge_home/entities/oven/const.py @@ -0,0 +1,47 @@ +import bidict + +from homeassistant.components.water_heater import WaterHeaterEntityFeature +from gehomesdk import ErdOvenCookMode + +SUPPORT_NONE = WaterHeaterEntityFeature(0) +GE_OVEN_SUPPORT = (WaterHeaterEntityFeature.OPERATION_MODE | WaterHeaterEntityFeature.TARGET_TEMPERATURE) + +OP_MODE_OFF = "Off" +OP_MODE_BAKE = "Bake" +OP_MODE_CONVMULTIBAKE = "Conv. Multi-Bake" +OP_MODE_CONVBAKE = "Convection Bake" +OP_MODE_CONVROAST = "Convection Roast" +OP_MODE_COOK_UNK = "Unknown" +OP_MODE_PIZZA = "Frozen Pizza" +OP_MODE_FROZEN_SNACKS = "Frozen Snacks" +OP_MODE_BAKED_GOODS = "Baked Goods" +OP_MODE_FROZEN_PIZZA_MULTI = "Frozen Pizza Multi" +OP_MODE_FROZEN_SNACKS_MULTI = "Frozen Snacks Multi" +OP_MODE_BROIL_HIGH = "Broil High" +OP_MODE_BROIL_LOW = "Broil Low" +OP_MODE_PROOF = "Proof" +OP_MODE_WARM = "Warm" + +OP_MODE_AIRFRY = "Air Fry" + +UPPER_OVEN = "UPPER_OVEN" +LOWER_OVEN = "LOWER_OVEN" + +COOK_MODE_OP_MAP = bidict.bidict({ + ErdOvenCookMode.NOMODE: OP_MODE_OFF, + ErdOvenCookMode.CONVMULTIBAKE_NOOPTION: OP_MODE_CONVMULTIBAKE, + ErdOvenCookMode.CONVBAKE_NOOPTION: OP_MODE_CONVBAKE, + ErdOvenCookMode.CONVROAST_NOOPTION: OP_MODE_CONVROAST, + ErdOvenCookMode.BROIL_LOW: OP_MODE_BROIL_LOW, + ErdOvenCookMode.BROIL_HIGH: OP_MODE_BROIL_HIGH, + ErdOvenCookMode.BAKE_NOOPTION: OP_MODE_BAKE, + ErdOvenCookMode.PROOF_NOOPTION: OP_MODE_PROOF, + ErdOvenCookMode.WARM_NOOPTION: OP_MODE_WARM, + ErdOvenCookMode.FROZEN_PIZZA: OP_MODE_PIZZA, + ErdOvenCookMode.FROZEN_SNACKS: OP_MODE_FROZEN_SNACKS, + ErdOvenCookMode.BAKED_GOODS: OP_MODE_BAKED_GOODS, + ErdOvenCookMode.FROZEN_PIZZA_MULTI: OP_MODE_FROZEN_PIZZA_MULTI, + ErdOvenCookMode.FROZEN_SNACKS_MULTI: OP_MODE_FROZEN_SNACKS_MULTI, + ErdOvenCookMode.AIRFRY: OP_MODE_AIRFRY +}) + diff --git a/custom_components/ge_home/entities/oven/ge_oven.py b/custom_components/ge_home/entities/oven/ge_oven.py new file mode 100644 index 0000000..710178f --- /dev/null +++ b/custom_components/ge_home/entities/oven/ge_oven.py @@ -0,0 +1,229 @@ +"""GE Home Sensor Entities - Oven""" +import logging +from typing import Any, Dict, List, Optional, Set + +from gehomesdk import ( + ErdCode, + ErdMeasurementUnits, + ErdOvenCookMode, + OVEN_COOK_MODE_MAP, + OvenCookSetting +) + +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from ...const import DOMAIN +from ...devices import ApplianceApi +from ..common import GeAbstractWaterHeater +from .const import * + +_LOGGER = logging.getLogger(__name__) + +class GeOven(GeAbstractWaterHeater): + """GE Appliance Oven""" + + icon = "mdi:stove" + + def __init__(self, api: ApplianceApi, oven_select: str = UPPER_OVEN, two_cavity: bool = False, temperature_erd_code: str = "RAW_TEMPERATURE"): + if oven_select not in (UPPER_OVEN, LOWER_OVEN): + raise ValueError(f"Invalid `oven_select` value ({oven_select})") + + self._oven_select = oven_select + self._two_cavity = two_cavity + self._temperature_erd_code = temperature_erd_code + super().__init__(api) + + @property + def supported_features(self): + if self.remote_enabled: + return GE_OVEN_SUPPORT + else: + return SUPPORT_NONE + + @property + def unique_id(self) -> str: + return f"{DOMAIN}_{self.serial_or_mac}_{self.oven_select.lower()}" + + @property + def name(self) -> Optional[str]: + if self._two_cavity: + oven_title = self.oven_select.replace("_", " ").title() + else: + oven_title = "Oven" + + return f"{self.serial_or_mac} {oven_title}" + + @property + def temperature_unit(self): + measurement_system = self.appliance.get_erd_value(ErdCode.TEMPERATURE_UNIT) + if measurement_system == ErdMeasurementUnits.METRIC: + return UnitOfTemperature.CELSIUS + return UnitOfTemperature.FAHRENHEIT + + @property + def oven_select(self) -> str: + return self._oven_select + + def get_erd_code(self, suffix: str) -> ErdCode: + """Return the appropriate ERD code for this oven_select""" + return ErdCode[f"{self.oven_select}_{suffix}"] + + @property + def remote_enabled(self) -> bool: + """Returns whether the oven is remote enabled""" + value = self.get_erd_value("REMOTE_ENABLED") + return value == True + + @property + def current_temperature(self) -> Optional[int]: + #DISPLAY_TEMPERATURE appears to be out of line with what's + #actually going on in the oven, RAW_TEMPERATURE seems to be + #accurate. However, it appears some devices don't have + #the raw temperature. So, we'll allow an override to handle + #that situation (see constructor) + #current_temp = self.get_erd_value("DISPLAY_TEMPERATURE") + #if current_temp: + # return current_temp + return self.get_erd_value(self._temperature_erd_code) + + @property + def current_operation(self) -> Optional[str]: + cook_setting = self.current_cook_setting + cook_mode = cook_setting.cook_mode + # TODO: simplify this lookup nonsense somehow + current_state = OVEN_COOK_MODE_MAP.inverse[cook_mode] + try: + return COOK_MODE_OP_MAP[current_state] + except KeyError: + _LOGGER.debug(f"Unable to map {current_state} to an operation mode") + return OP_MODE_COOK_UNK + + @property + def operation_list(self) -> List[str]: + #lookup all the available cook modes + erd_code = self.get_erd_code("AVAILABLE_COOK_MODES") + cook_modes: Set[ErdOvenCookMode] = self.appliance.get_erd_value(erd_code) + _LOGGER.debug(f"Available Cook Modes: {cook_modes}") + + #get the extended cook modes and add them to the list + ext_erd_code = self.get_erd_code("EXTENDED_COOK_MODES") + ext_cook_modes: Set[ErdOvenCookMode] = self.api.try_get_erd_value(ext_erd_code) + _LOGGER.debug(f"Extended Cook Modes: {ext_cook_modes}") + if ext_cook_modes: + cook_modes = cook_modes.union(ext_cook_modes) + + #make sure that we limit them to the list of known codes + cook_modes = cook_modes.intersection(COOK_MODE_OP_MAP.keys()) + + _LOGGER.debug(f"Final Cook Modes: {cook_modes}") + op_modes = [o for o in (COOK_MODE_OP_MAP[c] for c in cook_modes) if o] + op_modes = [OP_MODE_OFF] + op_modes + return op_modes + + @property + def current_cook_setting(self) -> OvenCookSetting: + """Get the current cook mode.""" + erd_code = self.get_erd_code("COOK_MODE") + return self.appliance.get_erd_value(erd_code) + + @property + def target_temperature(self) -> Optional[int]: + """Return the temperature we try to reach.""" + cook_mode = self.current_cook_setting + if cook_mode.temperature: + return cook_mode.temperature + return None + + @property + def min_temp(self) -> int: + """Return the minimum temperature.""" + min_temp, _ = self.appliance.get_erd_value(ErdCode.OVEN_MODE_MIN_MAX_TEMP) + return min_temp + + @property + def max_temp(self) -> int: + """Return the maximum temperature.""" + _, max_temp = self.appliance.get_erd_value(ErdCode.OVEN_MODE_MIN_MAX_TEMP) + return max_temp + + async def async_set_operation_mode(self, operation_mode: str): + """Set the operation mode.""" + + erd_cook_mode = COOK_MODE_OP_MAP.inverse[operation_mode] + # Pick a temperature to set. If there's not one already set, default to + # good old 350F. + if operation_mode == OP_MODE_OFF: + target_temp = 0 + elif self.target_temperature: + target_temp = self.target_temperature + elif self.temperature_unit == UnitOfTemperature.FAHRENHEIT: + target_temp = 350 + else: + target_temp = 177 + + new_cook_mode = OvenCookSetting(OVEN_COOK_MODE_MAP[erd_cook_mode], target_temp) + erd_code = self.get_erd_code("COOK_MODE") + await self.appliance.async_set_erd_value(erd_code, new_cook_mode) + + async def async_set_temperature(self, **kwargs): + """Set the cook temperature""" + + target_temp = kwargs.get(ATTR_TEMPERATURE) + if target_temp is None: + return + + current_op = self.current_operation + if current_op != OP_MODE_OFF: + erd_cook_mode = COOK_MODE_OP_MAP.inverse[current_op] + else: + erd_cook_mode = ErdOvenCookMode.BAKE_NOOPTION + + new_cook_mode = OvenCookSetting(OVEN_COOK_MODE_MAP[erd_cook_mode], target_temp) + erd_code = self.get_erd_code("COOK_MODE") + await self.appliance.async_set_erd_value(erd_code, new_cook_mode) + + def get_erd_value(self, suffix: str) -> Any: + erd_code = self.get_erd_code(suffix) + return self.appliance.get_erd_value(erd_code) + + @property + def display_state(self) -> Optional[str]: + erd_code = self.get_erd_code("CURRENT_STATE") + erd_value = self.appliance.get_erd_value(erd_code) + return self._stringify(erd_value, temp_units=self.temperature_unit) + + @property + def extra_state_attributes(self) -> Optional[Dict[str, Any]]: + probe_present = False + if self.api.has_erd_code(self.get_erd_code("PROBE_PRESENT")): + probe_present: bool = self.get_erd_value("PROBE_PRESENT") + data = { + "display_state": self.display_state, + "probe_present": probe_present, + "display_temperature": self.get_erd_value("DISPLAY_TEMPERATURE") + } + if self.api.has_erd_code(self.get_erd_code("RAW_TEMPERATURE")): + data["raw_temperature"] = self.get_erd_value("RAW_TEMPERATURE") + if probe_present: + data["probe_temperature"] = self.get_erd_value("PROBE_DISPLAY_TEMP") + + elapsed_time = None + cook_time_remaining = None + kitchen_timer = None + delay_time = None + if self.api.has_erd_code(self.get_erd_code("ELAPSED_COOK_TIME")): + elapsed_time = self.get_erd_value("ELAPSED_COOK_TIME") + if self.api.has_erd_code(self.get_erd_code("COOK_TIME_REMAINING")): + cook_time_remaining = self.get_erd_value("COOK_TIME_REMAINING") + if self.api.has_erd_code(self.get_erd_code("KITCHEN_TIMER")): + kitchen_timer = self.get_erd_value("KITCHEN_TIMER") + if self.api.has_erd_code(self.get_erd_code("DELAY_TIME_REMAINING")): + delay_time = self.get_erd_value("DELAY_TIME_REMAINING") + if elapsed_time: + data["cook_time_elapsed"] = self._stringify(elapsed_time) + if cook_time_remaining: + data["cook_time_remaining"] = self._stringify(cook_time_remaining) + if kitchen_timer: + data["cook_time_remaining"] = self._stringify(kitchen_timer) + if delay_time: + data["delay_time_remaining"] = self._stringify(delay_time) + return data diff --git a/custom_components/ge_home/entities/oven/ge_oven_light_level_select.py b/custom_components/ge_home/entities/oven/ge_oven_light_level_select.py new file mode 100644 index 0000000..ca7e0de --- /dev/null +++ b/custom_components/ge_home/entities/oven/ge_oven_light_level_select.py @@ -0,0 +1,66 @@ +import logging +from typing import List, Any, Optional + +from gehomesdk import ErdCodeType, ErdOvenLightLevelAvailability, ErdOvenLightLevel, ErdCode +from ...devices import ApplianceApi +from ..common import GeErdSelect, OptionsConverter + +_LOGGER = logging.getLogger(__name__) + +class OvenLightLevelOptionsConverter(OptionsConverter): + def __init__(self, availability: ErdOvenLightLevelAvailability): + super().__init__() + self.availability = availability + self.excluded_levels = [ErdOvenLightLevel.NOT_AVAILABLE] + + if not availability or not availability.dim_available: + self.excluded_levels.append(ErdOvenLightLevel.DIM) + + @property + def options(self) -> List[str]: + return [i.stringify() for i in ErdOvenLightLevel if i not in self.excluded_levels] + def from_option_string(self, value: str) -> Any: + try: + return ErdOvenLightLevel[value.upper()] + except: + _LOGGER.warning(f"Could not set Oven light level to {value.upper()}") + return ErdOvenLightLevel.OFF + def to_option_string(self, value: ErdOvenLightLevel) -> Optional[str]: + try: + if value is not None: + return value.stringify() + except: + pass + return ErdOvenLightLevel.OFF.stringify() + +class GeOvenLightLevelSelect(GeErdSelect): + + def __init__(self, api: ApplianceApi, erd_code: ErdCodeType, erd_override: str = None): + self._availability: ErdOvenLightLevelAvailability = api.try_get_erd_value(ErdCode.LOWER_OVEN_LIGHT_AVAILABILITY) + + #check to see if we have a status + value: ErdOvenLightLevel = api.try_get_erd_value(erd_code) + self._has_status = value is not None and value != ErdOvenLightLevel.NOT_AVAILABLE + self._assumed_state = ErdOvenLightLevel.OFF + + super().__init__(api, erd_code, OvenLightLevelOptionsConverter(self._availability), erd_override=erd_override) + + @property + def assumed_state(self) -> bool: + return not self._has_status + + @property + def current_option(self): + if self.assumed_state: + return self._assumed_state + + return self._converter.to_option_string(self.appliance.get_erd_value(self.erd_code)) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + _LOGGER.debug(f"Setting select from {self.current_option} to {option}") + + new_state = self._converter.from_option_string(option) + await self.appliance.async_set_erd_value(self.erd_code, new_state) + self._assumed_state = new_state + \ No newline at end of file diff --git a/custom_components/ge_home/entities/oven/ge_oven_warming_state_select.py b/custom_components/ge_home/entities/oven/ge_oven_warming_state_select.py new file mode 100644 index 0000000..86da410 --- /dev/null +++ b/custom_components/ge_home/entities/oven/ge_oven_warming_state_select.py @@ -0,0 +1,56 @@ +import logging +from typing import List, Any, Optional + +from gehomesdk import ErdCodeType, ErdOvenWarmingState +from ...devices import ApplianceApi +from ..common import GeErdSelect, OptionsConverter + +_LOGGER = logging.getLogger(__name__) + +class OvenWarmingStateOptionsConverter(OptionsConverter): + @property + def options(self) -> List[str]: + return [i.stringify() for i in ErdOvenWarmingState] + def from_option_string(self, value: str) -> Any: + try: + return ErdOvenWarmingState[value.upper()] + except: + _LOGGER.warning(f"Could not set Oven warming state to {value.upper()}") + return ErdOvenWarmingState.OFF + def to_option_string(self, value: ErdOvenWarmingState) -> Optional[str]: + try: + if value is not None: + return value.stringify() + except: + pass + return ErdOvenWarmingState.OFF.stringify() + +class GeOvenWarmingStateSelect(GeErdSelect): + + def __init__(self, api: ApplianceApi, erd_code: ErdCodeType, erd_override: str = None): + #check to see if we have a status + value: ErdOvenWarmingState = api.try_get_erd_value(erd_code) + self._has_status = value is not None and value != ErdOvenWarmingState.NOT_AVAILABLE + self._assumed_state = ErdOvenWarmingState.OFF + + super().__init__(api, erd_code, OvenWarmingStateOptionsConverter(self._availability), erd_override=erd_override) + + @property + def assumed_state(self) -> bool: + return not self._has_status + + @property + def current_option(self): + if self.assumed_state: + return self._assumed_state + + return self._converter.to_option_string(self.appliance.get_erd_value(self.erd_code)) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + _LOGGER.debug(f"Setting select from {self.current_option} to {option}") + + new_state = self._converter.from_option_string(option) + await self.appliance.async_set_erd_value(self.erd_code, new_state) + self._assumed_state = new_state + \ No newline at end of file diff --git a/custom_components/ge_home/entities/water_filter/__init__.py b/custom_components/ge_home/entities/water_filter/__init__.py new file mode 100644 index 0000000..1d37958 --- /dev/null +++ b/custom_components/ge_home/entities/water_filter/__init__.py @@ -0,0 +1 @@ +from .filter_position import GeErdFilterPositionSelect diff --git a/custom_components/ge_home/entities/water_filter/filter_position.py b/custom_components/ge_home/entities/water_filter/filter_position.py new file mode 100644 index 0000000..53038af --- /dev/null +++ b/custom_components/ge_home/entities/water_filter/filter_position.py @@ -0,0 +1,63 @@ +import logging +from typing import List, Any, Optional + +from gehomesdk import ErdCodeType, ErdWaterFilterPosition, ErdCode, ErdWaterFilterMode +from ...devices import ApplianceApi +from ..common import GeErdSelect, OptionsConverter + +_LOGGER = logging.getLogger(__name__) + +class FilterPositionOptionsConverter(OptionsConverter): + @property + def options(self) -> List[str]: + return [i.name.title() for i in ErdWaterFilterPosition if i != ErdWaterFilterPosition.UNKNOWN] + def from_option_string(self, value: str) -> Any: + try: + return ErdWaterFilterPosition[value.upper()] + except: + _LOGGER.warning(f"Could not set filter position to {value.upper()}") + return ErdWaterFilterPosition.UNKNOWN + def to_option_string(self, value: Any) -> Optional[str]: + try: + if value is not None: + return value.name.title() + except: + pass + return ErdWaterFilterPosition.UNKNOWN.name.title() + +class GeErdFilterPositionSelect(GeErdSelect): + def __init__(self, api: ApplianceApi, erd_code: ErdCodeType): + super().__init__(api, erd_code, FilterPositionOptionsConverter(), icon_override="mdi:valve") + + @property + def current_option(self): + """Return the current selected option""" + + #if we're transitioning or don't know what the mode is, don't allow changes + mode: ErdWaterFilterMode = self.appliance.get_erd_value(ErdCode.WH_FILTER_MODE) + if mode in [ErdWaterFilterMode.TRANSITION, ErdWaterFilterMode.UNKNOWN]: + return mode.name.title() + + return self._converter.to_option_string(self.appliance.get_erd_value(self.erd_code)) + + @property + def options(self) -> List[str]: + """Return a list of options""" + + #if we're transitioning or don't know what the mode is, don't allow changes + mode: ErdWaterFilterMode = self.appliance.get_erd_value(ErdCode.WH_FILTER_MODE) + if mode in [ErdWaterFilterMode.TRANSITION, ErdWaterFilterMode.UNKNOWN]: + return mode.name.title() + + return self._converter.options + + async def async_select_option(self, option: str) -> None: + value = self._converter.from_option_string(option) + if value in [ErdWaterFilterPosition.UNKNOWN, ErdWaterFilterPosition.READY]: + _LOGGER.debug("Cannot set position to ready/unknown") + return + if self.appliance.get_erd_value(self.erd_code) != ErdWaterFilterPosition.READY: + _LOGGER.debug("Cannot set position if not ready") + return + + return await super().async_select_option(option) diff --git a/custom_components/ge_home/entities/water_heater/__init__.py b/custom_components/ge_home/entities/water_heater/__init__.py new file mode 100644 index 0000000..c0fa79f --- /dev/null +++ b/custom_components/ge_home/entities/water_heater/__init__.py @@ -0,0 +1,2 @@ +from .heater_modes import WhHeaterModeConverter +from .ge_water_heater import GeWaterHeater \ No newline at end of file diff --git a/custom_components/ge_home/entities/water_heater/ge_water_heater.py b/custom_components/ge_home/entities/water_heater/ge_water_heater.py new file mode 100644 index 0000000..e9958c4 --- /dev/null +++ b/custom_components/ge_home/entities/water_heater/ge_water_heater.py @@ -0,0 +1,86 @@ +"""GE Home Sensor Entities - Oven""" +import logging +from typing import List, Optional + +from gehomesdk import ( + ErdCode, + ErdWaterHeaterMode +) + +from homeassistant.components.water_heater import WaterHeaterEntityFeature + +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from ...devices import ApplianceApi +from ..common import GeAbstractWaterHeater +from .heater_modes import WhHeaterModeConverter + +_LOGGER = logging.getLogger(__name__) + +class GeWaterHeater(GeAbstractWaterHeater): + """GE Whole Home Water Heater""" + + icon = "mdi:water-boiler" + + def __init__(self, api: ApplianceApi): + super().__init__(api) + self._modes_converter = WhHeaterModeConverter() + + @property + def heater_type(self) -> str: + return "heater" + + @property + def supported_features(self): + return (WaterHeaterEntityFeature.OPERATION_MODE | WaterHeaterEntityFeature.TARGET_TEMPERATURE) + + @property + def temperature_unit(self): + return UnitOfTemperature.FAHRENHEIT + + @property + def current_temperature(self) -> Optional[int]: + return self.appliance.get_erd_value(ErdCode.WH_HEATER_TEMPERATURE) + + @property + def current_operation(self) -> Optional[str]: + erd_mode = self.appliance.get_erd_value(ErdCode.WH_HEATER_MODE) + return self._modes_converter.to_option_string(erd_mode) + + @property + def operation_list(self) -> List[str]: + return self._modes_converter.options + + @property + def target_temperature(self) -> Optional[int]: + """Return the temperature we try to reach.""" + return self.appliance.get_erd_value(ErdCode.WH_HEATER_TARGET_TEMPERATURE) + + @property + def min_temp(self) -> int: + """Return the minimum temperature.""" + min_temp, _ = self.appliance.get_erd_value(ErdCode.WH_HEATER_MIN_MAX_TEMPERATURE) + return min_temp + + @property + def max_temp(self) -> int: + """Return the maximum temperature.""" + _, max_temp = self.appliance.get_erd_value(ErdCode.WH_HEATER_MIN_MAX_TEMPERATURE) + return max_temp + + async def async_set_operation_mode(self, operation_mode: str): + """Set the operation mode.""" + + erd_mode = self._modes_converter.from_option_string(operation_mode) + + if (erd_mode != ErdWaterHeaterMode.UNKNOWN): + await self.appliance.async_set_erd_value(ErdCode.WH_HEATER_MODE, erd_mode) + + async def async_set_temperature(self, **kwargs): + """Set the water temperature""" + + target_temp = kwargs.get(ATTR_TEMPERATURE) + if target_temp is None: + return + + await self.appliance.async_set_erd_value(ErdCode.WH_HEATER_TARGET_TEMPERATURE, target_temp) + diff --git a/custom_components/ge_home/entities/water_heater/heater_modes.py b/custom_components/ge_home/entities/water_heater/heater_modes.py new file mode 100644 index 0000000..145bb20 --- /dev/null +++ b/custom_components/ge_home/entities/water_heater/heater_modes.py @@ -0,0 +1,26 @@ +import logging +from typing import List, Any, Optional + +from gehomesdk import ErdWaterHeaterMode +from ..common import OptionsConverter + +_LOGGER = logging.getLogger(__name__) + +class WhHeaterModeConverter(OptionsConverter): + @property + def options(self) -> List[str]: + return [i.stringify() for i in ErdWaterHeaterMode] + def from_option_string(self, value: str) -> Any: + enum_val = value.upper().replace(" ","_") + try: + return ErdWaterHeaterMode[enum_val] + except: + _LOGGER.warning(f"Could not heater mode to {enum_val}") + return ErdWaterHeaterMode.UNKNOWN + def to_option_string(self, value: ErdWaterHeaterMode) -> Optional[str]: + try: + if value is not None: + return value.stringify() + except: + pass + return ErdWaterHeaterMode.UNKNOWN.stringify() diff --git a/custom_components/ge_home/entities/water_softener/__init__.py b/custom_components/ge_home/entities/water_softener/__init__.py new file mode 100644 index 0000000..7ae738e --- /dev/null +++ b/custom_components/ge_home/entities/water_softener/__init__.py @@ -0,0 +1 @@ +from .shutoff_position import GeErdShutoffPositionSelect \ No newline at end of file diff --git a/custom_components/ge_home/entities/water_softener/shutoff_position.py b/custom_components/ge_home/entities/water_softener/shutoff_position.py new file mode 100644 index 0000000..53d08a4 --- /dev/null +++ b/custom_components/ge_home/entities/water_softener/shutoff_position.py @@ -0,0 +1,65 @@ +import logging +from typing import List, Any, Optional + +from gehomesdk import ErdCodeType, ErdWaterSoftenerShutoffValveState, ErdCode +from ...devices import ApplianceApi +from ..common import GeErdSelect, OptionsConverter + +_LOGGER = logging.getLogger(__name__) + +class FilterPositionOptionsConverter(OptionsConverter): + @property + def options(self) -> List[str]: + return [i.name.title() + for i in ErdWaterSoftenerShutoffValveState + if i not in [ErdWaterSoftenerShutoffValveState.UNKNOWN, ErdWaterSoftenerShutoffValveState.TRANSITION]] + def from_option_string(self, value: str) -> Any: + try: + return ErdWaterSoftenerShutoffValveState[value.upper()] + except: + _LOGGER.warning(f"Could not set filter position to {value.upper()}") + return ErdWaterSoftenerShutoffValveState.UNKNOWN + def to_option_string(self, value: Any) -> Optional[str]: + try: + if value is not None: + return value.name.title() + except: + pass + return ErdWaterSoftenerShutoffValveState.UNKNOWN.name.title() + +class GeErdShutoffPositionSelect(GeErdSelect): + def __init__(self, api: ApplianceApi, erd_code: ErdCodeType): + super().__init__(api, erd_code, FilterPositionOptionsConverter(), icon_override="mdi:valve") + + @property + def current_option(self): + """Return the current selected option""" + + #if we're transitioning or don't know what the mode is, don't allow changes + mode: ErdWaterSoftenerShutoffValveState = self.appliance.get_erd_value(ErdCode.WH_SOFTENER_SHUTOFF_VALVE_STATE) + if mode in [ErdWaterSoftenerShutoffValveState.TRANSITION, ErdWaterSoftenerShutoffValveState.UNKNOWN]: + return mode.name.title() + + return self._converter.to_option_string(self.appliance.get_erd_value(self.erd_code)) + + @property + def options(self) -> List[str]: + """Return a list of options""" + + #if we're transitioning or don't know what the mode is, don't allow changes + mode: ErdWaterSoftenerShutoffValveState = self.appliance.get_erd_value(ErdCode.WH_SOFTENER_SHUTOFF_VALVE_STATE) + if mode in [ErdWaterSoftenerShutoffValveState.TRANSITION, ErdWaterSoftenerShutoffValveState.UNKNOWN]: + return mode.name.title() + + return self._converter.options + + async def async_select_option(self, option: str) -> None: + value = self._converter.from_option_string(option) + if value in [ErdWaterSoftenerShutoffValveState.UNKNOWN, ErdWaterSoftenerShutoffValveState.TRANSITION]: + _LOGGER.debug("Cannot set position to transition/unknown") + return + if self.appliance.get_erd_value(ErdCode.WH_SOFTENER_SHUTOFF_VALVE_STATE) == ErdWaterSoftenerShutoffValveState.TRANSITION: + _LOGGER.debug("Cannot set position if in transition") + return + + return await super().async_select_option(option) diff --git a/custom_components/ge_home/exceptions.py b/custom_components/ge_home/exceptions.py new file mode 100644 index 0000000..fe7946e --- /dev/null +++ b/custom_components/ge_home/exceptions.py @@ -0,0 +1,10 @@ +""" Home Assistant derived exceptions""" + +from homeassistant import exceptions as ha_exc + +class HaCannotConnect(ha_exc.HomeAssistantError): + """Error to indicate we cannot connect.""" +class HaAuthError(ha_exc.HomeAssistantError): + """Error to indicate authentication failure.""" +class HaAlreadyConfigured(ha_exc.HomeAssistantError): + """Error to indicate that the account is already configured""" \ No newline at end of file diff --git a/custom_components/ge_home/humidifier.py b/custom_components/ge_home/humidifier.py new file mode 100644 index 0000000..68aa896 --- /dev/null +++ b/custom_components/ge_home/humidifier.py @@ -0,0 +1,44 @@ +"""GE Home Humidifier Entities""" +import logging +from typing import Callable + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers import entity_registry as er + +from .entities import GeHumidifier +from .const import DOMAIN +from .devices import ApplianceApi +from .update_coordinator import GeHomeUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable): + """GE Home Water Heaters.""" + _LOGGER.debug('Adding GE "Humidifiers"') + coordinator: GeHomeUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + registry = er.async_get(hass) + + @callback + def async_devices_discovered(apis: list[ApplianceApi]): + _LOGGER.debug(f'Found {len(apis):d} appliance APIs') + entities = [ + entity + for api in apis + for entity in api.entities + if isinstance(entity, GeHumidifier) + if not registry.async_is_registered(entity.entity_id) + ] + _LOGGER.debug(f'Found {len(entities):d} unregistered humidifiers') + async_add_entities(entities) + + #if we're already initialized at this point, call device + #discovery directly, otherwise add a callback based on the + #ready signal + if coordinator.initialized: + async_devices_discovered(coordinator.appliance_apis.values()) + else: + # add the ready signal and register the remove callback + coordinator.add_signal_remove_callback( + async_dispatcher_connect(hass, coordinator.signal_ready, async_devices_discovered)) diff --git a/custom_components/ge_home/light.py b/custom_components/ge_home/light.py new file mode 100644 index 0000000..ba2a69c --- /dev/null +++ b/custom_components/ge_home/light.py @@ -0,0 +1,48 @@ +"""GE Home Select Entities""" +import logging +from typing import Callable + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers import entity_registry as er + +from .const import DOMAIN +from .entities import GeErdLight +from .devices import ApplianceApi +from .update_coordinator import GeHomeUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable +): + """GE Home lights.""" + _LOGGER.debug("Adding GE Home lights") + coordinator: GeHomeUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + registry = er.async_get(hass) + + @callback + def async_devices_discovered(apis: list[ApplianceApi]): + _LOGGER.debug(f"Found {len(apis):d} appliance APIs") + entities = [ + entity + for api in apis + for entity in api.entities + if isinstance(entity, GeErdLight) + and entity.erd_code in api.appliance._property_cache + if not registry.async_is_registered(entity.entity_id) + ] + _LOGGER.debug(f"Found {len(entities):d} unregistered lights") + async_add_entities(entities) + + #if we're already initialized at this point, call device + #discovery directly, otherwise add a callback based on the + #ready signal + if coordinator.initialized: + async_devices_discovered(coordinator.appliance_apis.values()) + else: + # add the ready signal and register the remove callback + coordinator.add_signal_remove_callback( + async_dispatcher_connect(hass, coordinator.signal_ready, async_devices_discovered)) diff --git a/custom_components/ge_home/manifest.json b/custom_components/ge_home/manifest.json new file mode 100644 index 0000000..3573241 --- /dev/null +++ b/custom_components/ge_home/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "ge_home", + "name": "GE Home (SmartHQ)", + "config_flow": true, + "integration_type": "hub", + "iot_class": "cloud_push", + "documentation": "https://github.com/simbaja/ha_gehome", + "requirements": ["gehomesdk==2025.5.0","magicattr==0.1.6","slixmpp==1.8.3"], + "codeowners": ["@simbaja"], + "version": "2025.5.0" +} diff --git a/custom_components/ge_home/number.py b/custom_components/ge_home/number.py new file mode 100644 index 0000000..e691988 --- /dev/null +++ b/custom_components/ge_home/number.py @@ -0,0 +1,45 @@ +"""GE Home Number Entities""" +import logging +from typing import Callable + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers import entity_registry as er + +from .const import DOMAIN +from .devices import ApplianceApi +from .entities import GeErdNumber +from .update_coordinator import GeHomeUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable): + """GE Home numbers.""" + + _LOGGER.debug('Adding GE Number Entities') + coordinator: GeHomeUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + registry = er.async_get(hass) + + @callback + def async_devices_discovered(apis: list[ApplianceApi]): + _LOGGER.debug(f'Found {len(apis):d} appliance APIs') + entities = [ + entity + for api in apis + for entity in api.entities + if isinstance(entity, GeErdNumber) + if not registry.async_is_registered(entity.entity_id) + ] + _LOGGER.debug(f'Found {len(entities):d} unregistered numbers') + async_add_entities(entities) + + #if we're already initialized at this point, call device + #discovery directly, otherwise add a callback based on the + #ready signal + if coordinator.initialized: + async_devices_discovered(coordinator.appliance_apis.values()) + else: + # add the ready signal and register the remove callback + coordinator.add_signal_remove_callback( + async_dispatcher_connect(hass, coordinator.signal_ready, async_devices_discovered)) diff --git a/custom_components/ge_home/select.py b/custom_components/ge_home/select.py new file mode 100644 index 0000000..158af27 --- /dev/null +++ b/custom_components/ge_home/select.py @@ -0,0 +1,48 @@ +"""GE Home Select Entities""" +import logging +from typing import Callable + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers import entity_registry as er + +from .const import DOMAIN +from .devices import ApplianceApi +from .entities import GeErdSelect +from .update_coordinator import GeHomeUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable +): + """GE Home selects.""" + _LOGGER.debug("Adding GE Home selects") + coordinator: GeHomeUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + registry = er.async_get(hass) + + @callback + def async_devices_discovered(apis: list[ApplianceApi]): + _LOGGER.debug(f"Found {len(apis):d} appliance APIs") + entities = [ + entity + for api in apis + for entity in api.entities + if isinstance(entity, GeErdSelect) + and entity.erd_code in api.appliance._property_cache + if not registry.async_is_registered(entity.entity_id) + ] + _LOGGER.debug(f"Found {len(entities):d} unregistered selects") + async_add_entities(entities) + + #if we're already initialized at this point, call device + #discovery directly, otherwise add a callback based on the + #ready signal + if coordinator.initialized: + async_devices_discovered(coordinator.appliance_apis.values()) + else: + # add the ready signal and register the remove callback + coordinator.add_signal_remove_callback( + async_dispatcher_connect(hass, coordinator.signal_ready, async_devices_discovered)) diff --git a/custom_components/ge_home/sensor.py b/custom_components/ge_home/sensor.py new file mode 100644 index 0000000..0bf7ba6 --- /dev/null +++ b/custom_components/ge_home/sensor.py @@ -0,0 +1,88 @@ +"""GE Home Sensor Entities""" +import logging +from typing import Callable +import voluptuous as vol +from datetime import timedelta + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers import entity_platform +from homeassistant.helpers import entity_registry as er + +from .const import ( + DOMAIN, + SERVICE_SET_TIMER, + SERVICE_CLEAR_TIMER, + SERVICE_SET_INT_VALUE +) +from .entities import GeErdSensor +from .devices import ApplianceApi +from .update_coordinator import GeHomeUpdateCoordinator + +ATTR_DURATION = "duration" +ATTR_VALUE = "value" + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable): + """GE Home sensors.""" + _LOGGER.debug('Adding GE Home sensors') + coordinator: GeHomeUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + registry = er.async_get(hass) + + # Get the platform + platform = entity_platform.async_get_current_platform() + + @callback + def async_devices_discovered(apis: list[ApplianceApi]): + _LOGGER.debug(f'Found {len(apis):d} appliance APIs') + entities = [ + entity + for api in apis + for entity in api.entities + if isinstance(entity, GeErdSensor) and entity.erd_code in api.appliance._property_cache + if not registry.async_is_registered(entity.entity_id) + ] + _LOGGER.debug(f'Found {len(entities):d} unregistered sensors') + async_add_entities(entities) + + #if we're already initialized at this point, call device + #discovery directly, otherwise add a callback based on the + #ready signal + if coordinator.initialized: + async_devices_discovered(coordinator.appliance_apis.values()) + else: + # add the ready signal and register the remove callback + coordinator.add_signal_remove_callback( + async_dispatcher_connect(hass, coordinator.signal_ready, async_devices_discovered)) + + # register set_timer entity service + platform.async_register_entity_service( + SERVICE_SET_TIMER, + { + vol.Required(ATTR_DURATION): vol.All( + vol.Coerce(int), vol.Range(min=1, max=360) + ) + }, + set_timer) + + # register clear_timer entity service + platform.async_register_entity_service(SERVICE_CLEAR_TIMER, {}, 'clear_timer') + + # register set_value entity service + platform.async_register_entity_service( + SERVICE_SET_INT_VALUE, + { + vol.Required(ATTR_VALUE): vol.All( + vol.Coerce(int), vol.Range(min=0) + ) + }, + set_int_value) + +async def set_timer(entity, service_call): + ts = timedelta(minutes=int(service_call.data['duration'])) + await entity.set_timer(ts) + +async def set_int_value(entity, service_call): + await entity.set_value(int(service_call.data['value'])) \ No newline at end of file diff --git a/custom_components/ge_home/services.yaml b/custom_components/ge_home/services.yaml new file mode 100644 index 0000000..1ca48ea --- /dev/null +++ b/custom_components/ge_home/services.yaml @@ -0,0 +1,47 @@ +# GE Home Services + +set_timer: + name: Set Timer + description: Sets a timer value (timespan) + target: + entity: + integration: "ge_home" + domain: "sensor" + fields: + duration: + name: Duration + description: Duration of the timer (minutes) + required: true + example: "90" + default: "30" + selector: + number: + min: 1 + max: 360 + unit_of_measurement: minutes + mode: slider +clear_timer: + name: Clear Timer + description: Clears a timer value (sets to zero) + target: + entity: + integration: "ge_home" + domain: "sensor" + +set_int_value: + name: Set Int Value + description: Sets an integer value (also can be used with ERD enums) + target: + entity: + integration: "ge_home" + domain: "sensor" + fields: + value: + name: Value + description: The value to set + required: true + selector: + number: + min: 0 + max: 65535 + \ No newline at end of file diff --git a/custom_components/ge_home/strings.json b/custom_components/ge_home/strings.json new file mode 100644 index 0000000..8e3c913 --- /dev/null +++ b/custom_components/ge_home/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Username", + "password": "Password", + "region": "Region" + } + } + }, + "error": { + "cannot_connect": "Can't connect to SmartHQ", + "invalid_auth": "Invalid authentication provided, please check credentials", + "unknown": "Unknown error occurred" + }, + "abort": { + "already_configured_account": "Account already configured!" + } + } +} diff --git a/custom_components/ge_home/switch.py b/custom_components/ge_home/switch.py new file mode 100644 index 0000000..7c339ae --- /dev/null +++ b/custom_components/ge_home/switch.py @@ -0,0 +1,44 @@ +"""GE Home Switch Entities""" +import logging +from typing import Callable + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers import entity_registry as er + +from .entities import GeErdSwitch +from .const import DOMAIN +from .devices import ApplianceApi +from .update_coordinator import GeHomeUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable): + """GE Home sensors.""" + _LOGGER.debug('Adding GE Home switches') + coordinator: GeHomeUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + registry = er.async_get(hass) + + @callback + def async_devices_discovered(apis: list[ApplianceApi]): + _LOGGER.debug(f'Found {len(apis):d} appliance APIs') + entities = [ + entity + for api in apis + for entity in api.entities + if isinstance(entity, GeErdSwitch) and entity.erd_code in api.appliance._property_cache + if not registry.async_is_registered(entity.entity_id) + ] + _LOGGER.debug(f'Found {len(entities):d} unregistered switches') + async_add_entities(entities) + + #if we're already initialized at this point, call device + #discovery directly, otherwise add a callback based on the + #ready signal + if coordinator.initialized: + async_devices_discovered(coordinator.appliance_apis.values()) + else: + # add the ready signal and register the remove callback + coordinator.add_signal_remove_callback( + async_dispatcher_connect(hass, coordinator.signal_ready, async_devices_discovered)) diff --git a/ge_kitchen/translations/en.json b/custom_components/ge_home/translations/en.json similarity index 63% rename from ge_kitchen/translations/en.json rename to custom_components/ge_home/translations/en.json index 142a25e..50680ea 100644 --- a/ge_kitchen/translations/en.json +++ b/custom_components/ge_home/translations/en.json @@ -1,17 +1,19 @@ { - "title": "GE Kitchen", + "title": "GE Home", "config": { "step": { "init": { "data": { "username": "Username", - "password": "Password" + "password": "Password", + "region": "Region" } }, "user": { "data": { "username": "Username", - "password": "Password" + "password": "Password", + "region": "Region" } } }, @@ -21,7 +23,7 @@ "unknown": "Unexpected error" }, "abort": { - "already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured_account": "Account is already configured" } } -} \ No newline at end of file +} diff --git a/custom_components/ge_home/update_coordinator.py b/custom_components/ge_home/update_coordinator.py new file mode 100644 index 0000000..9bd27f4 --- /dev/null +++ b/custom_components/ge_home/update_coordinator.py @@ -0,0 +1,366 @@ +"""Data update coordinator for GE Home Appliances""" + +import asyncio +import async_timeout +import logging +from typing import Any, Callable, Dict, Iterable, Optional, Tuple, List + +from gehomesdk import ( + EVENT_APPLIANCE_INITIAL_UPDATE, + EVENT_APPLIANCE_UPDATE_RECEIVED, + EVENT_CONNECTED, + EVENT_DISCONNECTED, + EVENT_GOT_APPLIANCE_LIST, + ErdCodeType, + GeAppliance, + GeWebsocketClient, +) +from gehomesdk import GeAuthFailedError, GeGeneralServerError, GeNotAuthenticatedError +from .exceptions import HaAuthError, HaCannotConnect + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_REGION +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util.ssl import get_default_context +from .const import ( + DOMAIN, + EVENT_ALL_APPLIANCES_READY, + UPDATE_INTERVAL, + MIN_RETRY_DELAY, + MAX_RETRY_DELAY, + RETRY_OFFLINE_COUNT, + ASYNC_TIMEOUT, +) +from .devices import ApplianceApi, get_appliance_api_type + +PLATFORMS = [ + "binary_sensor", + "sensor", + "switch", + "water_heater", + "select", + "climate", + "light", + "button", + "number", + "humidifier" +] +_LOGGER = logging.getLogger(__name__) + + +class GeHomeUpdateCoordinator(DataUpdateCoordinator): + """Define a wrapper class to update GE Home data.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Set up the GeHomeUpdateCoordinator class.""" + super().__init__(hass, _LOGGER, name=DOMAIN) + + self._config_entry = config_entry + self._username = config_entry.data[CONF_USERNAME] + self._password = config_entry.data[CONF_PASSWORD] + self._region = config_entry.data[CONF_REGION] + self._appliance_apis = {} # type: Dict[str, ApplianceApi] + self._signal_remove_callbacks = [] # type: List[Callable] + + self._reset_initialization() + + def _reset_initialization(self): + self.client = None # type: Optional[GeWebsocketClient] + + # Mark all appliances as not initialized yet + for a in self.appliance_apis.values(): + a.appliance.initialized = False + + # Some record keeping to let us know when we can start generating entities + self._got_roster = False + self._init_done = False + self._retry_count = 0 + + def create_ge_client( + self, event_loop: Optional[asyncio.AbstractEventLoop] + ) -> GeWebsocketClient: + """ + Create a new GeClient object with some helpful callbacks. + + :param event_loop: Event loop + :return: GeWebsocketClient + """ + client = GeWebsocketClient( + self._username, + self._password, + self._region, + event_loop=event_loop, + ssl_context=get_default_context() + ) + client.add_event_handler(EVENT_APPLIANCE_INITIAL_UPDATE, self.on_device_initial_update) + client.add_event_handler(EVENT_APPLIANCE_UPDATE_RECEIVED, self.on_device_update) + client.add_event_handler(EVENT_GOT_APPLIANCE_LIST, self.on_appliance_list) + client.add_event_handler(EVENT_DISCONNECTED, self.on_disconnect) + client.add_event_handler(EVENT_CONNECTED, self.on_connect) + return client + + @property + def appliances(self) -> Iterable[GeAppliance]: + return self.client.appliances.values() + + @property + def appliance_apis(self) -> Dict[str, ApplianceApi]: + return self._appliance_apis + + @property + def signal_ready(self) -> str: + """Event specific per entry to signal readiness""" + return f"{DOMAIN}-ready-{self._config_entry.entry_id}" + + @property + def initialized(self) -> bool: + return self._init_done + + @property + def online(self) -> bool: + """ + Indicates whether the services is online. If it's retried several times, it's assumed + that it's offline for some reason + """ + return self.connected or self._retry_count <= RETRY_OFFLINE_COUNT + + @property + def connected(self) -> bool: + """ + Indicates whether the coordinator is connected + """ + return self.client and self.client.connected + + def _get_appliance_api(self, appliance: GeAppliance) -> ApplianceApi: + api_type = get_appliance_api_type(appliance.appliance_type) + return api_type(self, appliance) + + def regenerate_appliance_apis(self): + """Regenerate the appliance_apis dictionary, adding elements as necessary.""" + for jid, appliance in self.client.appliances.keys(): + if jid not in self._appliance_apis: + self._appliance_apis[jid] = self._get_appliance_api(appliance) + + def maybe_add_appliance_api(self, appliance: GeAppliance): + mac_addr = appliance.mac_addr + if mac_addr not in self.appliance_apis: + _LOGGER.debug(f"Adding appliance api for appliance {mac_addr} ({appliance.appliance_type})") + api = self._get_appliance_api(appliance) + api.build_entities_list() + self.appliance_apis[mac_addr] = api + else: + # if we already have the API, switch out its appliance reference for this one + api = self.appliance_apis[mac_addr] + api.appliance = appliance + + def add_signal_remove_callback(self, cb: Callable): + self._signal_remove_callbacks.append(cb) + + async def get_client(self) -> GeWebsocketClient: + """Get a new GE Websocket client.""" + if self.client: + try: + self.client.clear_event_handlers() + await self.client.disconnect() + except Exception as err: + _LOGGER.warning(f"exception while disconnecting client {err}") + finally: + self._reset_initialization() + + self.client = self.create_ge_client(event_loop=self.hass.loop) + return self.client + + async def async_setup(self): + """Setup a new coordinator""" + _LOGGER.debug("Setting up coordinator") + + await self.hass.config_entries.async_forward_entry_setups( + self._config_entry, PLATFORMS + ) + + try: + await self.async_start_client() + except (GeNotAuthenticatedError, GeAuthFailedError): + raise HaAuthError("Authentication failure") + except GeGeneralServerError: + raise HaCannotConnect("Cannot connect (server error)") + except Exception: + raise HaCannotConnect("Unknown connection failure") + + return True + + async def async_start_client(self): + """Start a new GeClient in the HASS event loop.""" + try: + _LOGGER.debug("Creating and starting client") + await self.get_client() + await self.async_begin_session() + except: + _LOGGER.debug("could not start the client") + self.client = None + raise + + async def async_begin_session(self): + """Begins the ge_home session.""" + _LOGGER.debug("Beginning session") + session = async_get_clientsession(self.hass) + await self.client.async_get_credentials(session) + fut = asyncio.ensure_future(self.client.async_run_client(), loop=self.hass.loop) + _LOGGER.debug("Client running") + return fut + + async def async_reset(self): + """Resets the coordinator.""" + _LOGGER.debug("resetting the coordinator") + entry = self._config_entry + + # remove all the callbacks for this coordinator + for c in self._signal_remove_callbacks: + c() + self._signal_remove_callbacks.clear() + + unload_ok = await self.hass.config_entries.async_unload_platforms( + self._config_entry, PLATFORMS + ) + return unload_ok + + async def _kill_client(self): + """Kill the client. Leaving this in for testing purposes.""" + await asyncio.sleep(30) + _LOGGER.critical("Killing the connection. Popcorn time.") + await self.client.disconnect() + + @callback + def reconnect(self, log=False) -> None: + """Prepare to reconnect ge_home session.""" + if log: + _LOGGER.info("Will try to reconnect to ge_home service") + self.hass.loop.create_task(self.async_reconnect()) + + async def async_reconnect(self) -> None: + """Try to reconnect ge_home session.""" + self._retry_count += 1 + _LOGGER.info( + f"attempting to reconnect to ge_home service (attempt {self._retry_count})" + ) + + try: + with async_timeout.timeout(ASYNC_TIMEOUT): + await self.async_start_client() + except Exception as err: + _LOGGER.warning(f"could not reconnect: {err}, will retry in {self._get_retry_delay()} seconds") + self.hass.loop.call_later(self._get_retry_delay(), self.reconnect) + _LOGGER.debug("forcing a state refresh while disconnected") + try: + await self._refresh_ha_state() + except Exception as err: + _LOGGER.debug(f"error refreshing state: {err}") + + @callback + def shutdown(self, event) -> None: + """Close the connection on shutdown. + Used as an argument to EventBus.async_listen_once. + """ + _LOGGER.info("ge_home shutting down") + if self.client: + self.client.clear_event_handlers() + self.hass.loop.create_task(self.client.disconnect()) + + async def on_device_update(self, data: Tuple[GeAppliance, Dict[ErdCodeType, Any]]): + """Let HA know there's new state.""" + self.last_update_success = True + appliance, _ = data + try: + api = self.appliance_apis[appliance.mac_addr] + except KeyError: + _LOGGER.info(f"Could not find appliance {appliance.mac_addr} in known device list.") + return + + self._update_entity_state(api.entities) + + async def _refresh_ha_state(self): + entities = [ + entity for api in self.appliance_apis.values() for entity in api.entities + ] + + self._update_entity_state(entities) + + def _update_entity_state(self, entities: List[Entity]): + from .entities import GeEntity + for entity in entities: + # if this is a GeEntity, check if it's been added + #if not, don't try to refresh this entity + if isinstance(entity, GeEntity): + gee: GeEntity = entity + if not gee.added: + _LOGGER.debug(f"Entity {entity} ({entity.unique_id}, {entity.entity_id}) not yet added, skipping update...") + continue + if entity.enabled: + try: + _LOGGER.debug(f"Refreshing state for {entity} ({entity.unique_id}, {entity.entity_id}") + entity.async_write_ha_state() + except: + _LOGGER.warning(f"Could not refresh state for {entity} ({entity.unique_id}, {entity.entity_id}", exc_info=1) + + @property + def all_appliances_updated(self) -> bool: + """True if all appliances have had an initial update.""" + return all([a.initialized for a in self.appliances]) + + async def on_appliance_list(self, _): + """When we get an appliance list, mark it and maybe trigger all ready.""" + _LOGGER.debug("Got roster update") + self.last_update_success = True + if not self._got_roster: + self._got_roster = True + # TODO: Probably should have a better way of confirming we're good to go... + await asyncio.sleep(5) + # After the initial roster update, wait a bit and hit go + await self.async_maybe_trigger_all_ready() + + async def on_device_initial_update(self, appliance: GeAppliance): + """When an appliance first becomes ready, let the system know and schedule periodic updates.""" + _LOGGER.debug(f"Got initial update for {appliance.mac_addr}") + self.last_update_success = True + self.maybe_add_appliance_api(appliance) + await self.async_maybe_trigger_all_ready() + _LOGGER.debug(f"Requesting updates for {appliance.mac_addr}") + while self.connected: + await asyncio.sleep(UPDATE_INTERVAL) + if self.connected and self.client.available: + await appliance.async_request_update() + + _LOGGER.debug(f"No longer requesting updates for {appliance.mac_addr}") + + async def on_disconnect(self, _): + """Handle disconnection.""" + _LOGGER.debug(f"Disconnected. Attempting to reconnect in {MIN_RETRY_DELAY} seconds") + self.last_update_success = False + self.hass.loop.call_later(MIN_RETRY_DELAY, self.reconnect, True) + + async def on_connect(self, _): + """Set state upon connection.""" + self.last_update_success = True + self._retry_count = 0 + + async def async_maybe_trigger_all_ready(self): + """See if we're all ready to go, and if so, let the games begin.""" + if self._init_done: + # Been here, done this + return + if self._got_roster and self.all_appliances_updated: + _LOGGER.debug("Ready to go, sending ready signal") + self._init_done = True + await self.client.async_event(EVENT_ALL_APPLIANCES_READY, None) + async_dispatcher_send( + self.hass, + self.signal_ready, + list(self.appliance_apis.values())) + + def _get_retry_delay(self) -> int: + delay = MIN_RETRY_DELAY * 2 ** (self._retry_count - 1) + return min(delay, MAX_RETRY_DELAY) diff --git a/custom_components/ge_home/water_heater.py b/custom_components/ge_home/water_heater.py new file mode 100644 index 0000000..9bb0a8e --- /dev/null +++ b/custom_components/ge_home/water_heater.py @@ -0,0 +1,46 @@ +"""GE Home Sensor Entities""" +import async_timeout +import logging +from typing import Callable + +from homeassistant.components.water_heater import WaterHeaterEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers import entity_registry as er + +from .entities import GeAbstractWaterHeater +from .const import DOMAIN +from .devices import ApplianceApi +from .update_coordinator import GeHomeUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable): + """GE Home Water Heaters.""" + _LOGGER.debug('Adding GE "Water Heaters"') + coordinator: GeHomeUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + registry = er.async_get(hass) + + @callback + def async_devices_discovered(apis: list[ApplianceApi]): + _LOGGER.debug(f'Found {len(apis):d} appliance APIs') + entities = [ + entity + for api in apis + for entity in api.entities + if isinstance(entity, GeAbstractWaterHeater) + if not registry.async_is_registered(entity.entity_id) + ] + _LOGGER.debug(f'Found {len(entities):d} unregistered water heaters') + async_add_entities(entities) + + #if we're already initialized at this point, call device + #discovery directly, otherwise add a callback based on the + #ready signal + if coordinator.initialized: + async_devices_discovered(coordinator.appliance_apis.values()) + else: + # add the ready signal and register the remove callback + coordinator.add_signal_remove_callback( + async_dispatcher_connect(hass, coordinator.signal_ready, async_devices_discovered)) diff --git a/ge_kitchen/__init__.py b/ge_kitchen/__init__.py deleted file mode 100644 index 9b8cff0..0000000 --- a/ge_kitchen/__init__.py +++ /dev/null @@ -1,83 +0,0 @@ -"""The ge_kitchen integration.""" - -import asyncio -import async_timeout -import logging -import voluptuous as vol - -from gekitchen import GeAuthError, GeServerError - -from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT -from homeassistant.const import CONF_USERNAME -from homeassistant.core import HomeAssistant -from . import config_flow -from .const import ( - AUTH_HANDLER, - COORDINATOR, - DOMAIN, - OAUTH2_AUTH_URL, - OAUTH2_TOKEN_URL, -) -from .exceptions import AuthError, CannotConnect -from .update_coordinator import GeKitchenUpdateCoordinator - -CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) -PLATFORMS = ["binary_sensor", "sensor", "switch", "water_heater"] - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup(hass: HomeAssistant, config: dict): - """Set up the ge_kitchen component.""" - hass.data.setdefault(DOMAIN, {}) - if DOMAIN not in config: - return True - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): - """Set up ge_kitchen from a config entry.""" - coordinator = GeKitchenUpdateCoordinator(hass, entry) - hass.data[DOMAIN][entry.entry_id] = coordinator - - try: - await coordinator.async_start_client() - except GeAuthError: - raise AuthError('Authentication failure') - except GeServerError: - raise CannotConnect('Cannot connect (server error)') - except Exception: - raise CannotConnect('Unknown connection failure') - - try: - with async_timeout.timeout(30): - await coordinator.initialization_future - except TimeoutError: - raise CannotConnect('Initialization timed out') - - for component in PLATFORMS: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, component) - ) - - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): - """Unload a config entry.""" - unload_ok = all( - await asyncio.gather( - *[ - hass.config_entries.async_forward_entry_unload(entry, component) - for component in PLATFORMS - ] - ) - ) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok - - -async def async_update_options(hass, config_entry): - """Update options.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/ge_kitchen/appliance_api.py b/ge_kitchen/appliance_api.py deleted file mode 100644 index f91b6b3..0000000 --- a/ge_kitchen/appliance_api.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Oven state representation.""" - -import asyncio -import logging -from typing import Dict, List, Optional, Type, TYPE_CHECKING - -from gekitchen import GeAppliance -from gekitchen.erd_constants import * -from gekitchen.erd_types import * - -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from .binary_sensor import GeErdBinarySensor -from .const import DOMAIN -from .entities import GeErdEntity -from .sensor import GeErdSensor -from .switch import GeErdSwitch -from .water_heater import ( - GeFreezerEntity, - GeFridgeEntity, - GeOvenHeaterEntity, - LOWER_OVEN, - UPPER_OVEN, -) - - - - -_LOGGER = logging.getLogger(__name__) - - -def get_appliance_api_type(appliance_type: ErdApplianceType) -> Type: - """Get the appropriate appliance type""" - if appliance_type == ErdApplianceType.OVEN: - return OvenApi - if appliance_type == ErdApplianceType.FRIDGE: - return FridgeApi - # Fallback - return ApplianceApi - - -class ApplianceApi: - """ - API class to represent a single physical device. - - Since a physical device can have many entities, we"ll pool common elements here - """ - APPLIANCE_TYPE = None # type: Optional[ErdApplianceType] - - def __init__(self, coordinator: DataUpdateCoordinator, appliance: GeAppliance): - if not appliance.initialized: - raise RuntimeError("Appliance not ready") - self._appliance = appliance - self._loop = appliance.client.loop - self._hass = coordinator.hass - self.coordinator = coordinator - self.initial_update = False - self._entities = {} # type: Optional[Dict[str, Entity]] - - @property - def hass(self) -> HomeAssistant: - return self._hass - - @property - def loop(self) -> Optional[asyncio.AbstractEventLoop]: - if self._loop is None: - self._loop = self._appliance.client.loop - return self._loop - - @property - def appliance(self) -> GeAppliance: - return self._appliance - - @property - def serial_number(self) -> str: - return self.appliance.get_erd_value(ErdCode.SERIAL_NUMBER) - - @property - def model_number(self) -> str: - return self.appliance.get_erd_value(ErdCode.MODEL_NUMBER) - - @property - def name(self) -> str: - appliance_type = self.appliance.appliance_type - if appliance_type is None or appliance_type == ErdApplianceType.UNKNOWN: - appliance_type = "Appliance" - else: - appliance_type = appliance_type.name.replace("_", " ").title() - return f"GE {appliance_type} {self.serial_number}" - - @property - def device_info(self) -> Dict: - """Device info dictionary.""" - return { - "identifiers": {(DOMAIN, self.serial_number)}, - "name": self.name, - "manufacturer": "GE", - "model": self.model_number, - "sw_version": self.appliance.get_erd_value(ErdCode.WIFI_MODULE_SW_VERSION), - } - - @property - def entities(self) -> List[Entity]: - return list(self._entities.values()) - - def get_all_entities(self) -> List[Entity]: - """Create Entities for this device.""" - entities = [ - GeErdSensor(self, ErdCode.CLOCK_TIME), - GeErdSwitch(self, ErdCode.SABBATH_MODE), - ] - return entities - - def build_entities_list(self) -> None: - """Build the entities list, adding anything new.""" - entities = [ - e for e in self.get_all_entities() - if not isinstance(e, GeErdEntity) or e.erd_code in self.appliance.known_properties - ] - - for entity in entities: - if entity.unique_id not in self._entities: - self._entities[entity.unique_id] = entity - - -class OvenApi(ApplianceApi): - """API class for oven objects""" - APPLIANCE_TYPE = ErdApplianceType.OVEN - - def get_all_entities(self) -> List[Entity]: - base_entities = super().get_all_entities() - oven_config: OvenConfiguration = self.appliance.get_erd_value(ErdCode.OVEN_CONFIGURATION) - _LOGGER.debug(f"Oven Config: {oven_config}") - oven_entities = [ - GeErdSensor(self, ErdCode.UPPER_OVEN_COOK_MODE), - GeErdSensor(self, ErdCode.UPPER_OVEN_COOK_TIME_REMAINING), - GeErdSensor(self, ErdCode.UPPER_OVEN_KITCHEN_TIMER), - GeErdSensor(self, ErdCode.UPPER_OVEN_USER_TEMP_OFFSET), - GeErdBinarySensor(self, ErdCode.UPPER_OVEN_REMOTE_ENABLED), - ] - - if oven_config.has_lower_oven: - oven_entities.extend([ - GeErdSensor(self, ErdCode.LOWER_OVEN_COOK_MODE), - GeErdSensor(self, ErdCode.LOWER_OVEN_COOK_TIME_REMAINING), - GeErdSensor(self, ErdCode.LOWER_OVEN_USER_TEMP_OFFSET), - GeErdBinarySensor(self, ErdCode.LOWER_OVEN_REMOTE_ENABLED), - GeOvenHeaterEntity(self, LOWER_OVEN, True), - GeOvenHeaterEntity(self, UPPER_OVEN, True), - ]) - else: - oven_entities.append(GeOvenHeaterEntity(self, UPPER_OVEN, False)) - return base_entities + oven_entities - - -class FridgeApi(ApplianceApi): - """API class for oven objects""" - APPLIANCE_TYPE = ErdApplianceType.FRIDGE - - def get_all_entities(self) -> List[Entity]: - base_entities = super().get_all_entities() - - fridge_entities = [ - # GeErdSensor(self, ErdCode.AIR_FILTER_STATUS), - GeErdSensor(self, ErdCode.DOOR_STATUS), - GeErdSensor(self, ErdCode.FRIDGE_MODEL_INFO), - # GeErdSensor(self, ErdCode.HOT_WATER_LOCAL_USE), - # GeErdSensor(self, ErdCode.HOT_WATER_SET_TEMP), - # GeErdSensor(self, ErdCode.HOT_WATER_STATUS), - GeErdSwitch(self, ErdCode.SABBATH_MODE), - GeFreezerEntity(self), - GeFridgeEntity(self), - ] - entities = base_entities + fridge_entities - return entities diff --git a/ge_kitchen/binary_sensor.py b/ge_kitchen/binary_sensor.py deleted file mode 100644 index 18f1a9c..0000000 --- a/ge_kitchen/binary_sensor.py +++ /dev/null @@ -1,90 +0,0 @@ -"""GE Kitchen Sensor Entities""" -import async_timeout -import logging -from typing import Callable, Optional, TYPE_CHECKING - -from gekitchen import ErdCodeType - -from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant - -from .const import DOMAIN -from .entities import DOOR_ERD_CODES, GeErdEntity, boolify_erd_value, get_erd_icon - -if TYPE_CHECKING: - from .appliance_api import ApplianceApi - from .update_coordinator import GeKitchenUpdateCoordinator - - -_LOGGER = logging.getLogger(__name__) - - -class GeErdBinarySensor(GeErdEntity, BinarySensorEntity): - """GE Entity for binary sensors""" - @property - def is_on(self) -> bool: - """Return True if entity is on.""" - return bool(self.appliance.get_erd_value(self.erd_code)) - - @property - def icon(self) -> Optional[str]: - return get_erd_icon(self.erd_code, self.is_on) - - @property - def device_class(self) -> Optional[str]: - if self.erd_code in DOOR_ERD_CODES: - return "door" - return None - - -class GeErdPropertyBinarySensor(GeErdBinarySensor): - """GE Entity for property binary sensors""" - def __init__(self, api: "ApplianceApi", erd_code: ErdCodeType, erd_property: str): - super().__init__(api, erd_code) - self.erd_property = erd_property - - @property - def is_on(self) -> Optional[bool]: - """Return True if entity is on.""" - try: - value = getattr(self.appliance.get_erd_value(self.erd_code), self.erd_property) - except KeyError: - return None - return boolify_erd_value(self.erd_code, value) - - @property - def icon(self) -> Optional[str]: - return get_erd_icon(self.erd_code, self.is_on) - - @property - def unique_id(self) -> Optional[str]: - return f"{super().unique_id}_{self.erd_property}" - - @property - def name(self) -> Optional[str]: - base_string = super().name - property_name = self.erd_property.replace("_", " ").title() - return f"{base_string} {property_name}" - - -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable): - """GE Kitchen sensors.""" - - coordinator: "GeKitchenUpdateCoordinator" = hass.data[DOMAIN][config_entry.entry_id] - - # This should be a NOP, but let's be safe - with async_timeout.timeout(20): - await coordinator.initialization_future - - apis = coordinator.appliance_apis.values() - _LOGGER.debug(f'Found {len(apis):d} appliance APIs') - entities = [ - entity - for api in apis - for entity in api.entities - if isinstance(entity, GeErdBinarySensor) and not isinstance(entity, SwitchEntity) - ] - _LOGGER.debug(f'Found {len(entities):d} binary sensors ') - async_add_entities(entities) diff --git a/ge_kitchen/config_flow.py b/ge_kitchen/config_flow.py deleted file mode 100644 index 8c996f9..0000000 --- a/ge_kitchen/config_flow.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Config flow for GE Kitchen integration.""" - -import asyncio -import logging -from typing import Dict, Optional - -import aiohttp -import async_timeout -from gekitchen import GeAuthError, GeServerError, async_get_oauth2_token -import voluptuous as vol - -from homeassistant import config_entries, core -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME - -from .const import DOMAIN # pylint:disable=unused-import -from .exceptions import AuthError, CannotConnect - -_LOGGER = logging.getLogger(__name__) - -GEKITCHEN_SCHEMA = vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} -) - - -async def validate_input(hass: core.HomeAssistant, data): - """Validate the user input allows us to connect.""" - - session = hass.helpers.aiohttp_client.async_get_clientsession(hass) - - # noinspection PyBroadException - try: - with async_timeout.timeout(10): - _ = await async_get_oauth2_token(session, data[CONF_USERNAME], data[CONF_PASSWORD]) - except (asyncio.TimeoutError, aiohttp.ClientError): - raise CannotConnect('Connection failure') - except GeAuthError: - raise AuthError('Authentication failure') - except GeServerError: - raise CannotConnect('Cannot connect (server error)') - except Exception as exc: - _LOGGER.exception("Unkown connection failure", exc_info=exc) - raise CannotConnect('Unknown connection failure') - - # Return info that you want to store in the config entry. - return {"title": f"GE Kitchen ({data[CONF_USERNAME]:s})"} - - -class GeKitchenConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for GE Kitchen.""" - - VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH - - async def _async_validate_input(self, user_input): - """Validate form input.""" - errors = {} - info = None - - if user_input is not None: - # noinspection PyBroadException - try: - info = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except AuthError: - errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - return info, errors - - async def async_step_user(self, user_input: Optional[Dict] = None): - """Handle the initial step.""" - errors = {} - if user_input is not None: - info, errors = await self._async_validate_input(user_input) - if info: - return self.async_create_entry(title=info["title"], data=user_input) - - return self.async_show_form( - step_id="user", data_schema=GEKITCHEN_SCHEMA, errors=errors - ) - - async def async_step_reauth(self, user_input: Optional[dict] = None): - """Handle re-auth if login is invalid.""" - errors = {} - - if user_input is not None: - _, errors = await self._async_validate_input(user_input) - - if not errors: - for entry in self._async_current_entries(): - if entry.unique_id == self.unique_id: - self.hass.config_entries.async_update_entry( - entry, data=user_input - ) - await self.hass.config_entries.async_reload(entry.entry_id) - return self.async_abort(reason="reauth_successful") - - if errors["base"] != "invalid_auth": - return self.async_abort(reason=errors["base"]) - - return self.async_show_form( - step_id="reauth", data_schema=GEKITCHEN_SCHEMA, errors=errors, - ) diff --git a/ge_kitchen/const.py b/ge_kitchen/const.py deleted file mode 100644 index da77a82..0000000 --- a/ge_kitchen/const.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Constants for the ge_kitchen integration.""" -from gekitchen.const import LOGIN_URL - -DOMAIN = "ge_kitchen" - -# TODO Update with your own urls -# OAUTH2_AUTHORIZE = f"{LOGIN_URL}/oauth2/auth" -OAUTH2_AUTH_URL = f"{LOGIN_URL}/oauth2/auth" -OAUTH2_TOKEN_URL = f"{LOGIN_URL}/oauth2/token" - -AUTH_HANDLER = "auth_handler" -EVENT_ALL_APPLIANCES_READY = 'all_appliances_ready' -COORDINATOR = "coordinator" -GE_TOKEN = "ge_token" -MOBILE_DEVICE_TOKEN = "mdt" -XMPP_CREDENTIALS = "xmpp_credentials" - -UPDATE_INTERVAL = 30 diff --git a/ge_kitchen/entities.py b/ge_kitchen/entities.py deleted file mode 100644 index 14a9323..0000000 --- a/ge_kitchen/entities.py +++ /dev/null @@ -1,229 +0,0 @@ -"""Define all of the entity types""" - -import logging -from typing import Any, Dict, Optional, TYPE_CHECKING - -from gekitchen import ErdCodeType, GeAppliance, translate_erd_code -from gekitchen.erd_types import * -from gekitchen.erd_constants import * -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT -from homeassistant.core import HomeAssistant - - -from .const import DOMAIN -from .erd_string_utils import * - -if TYPE_CHECKING: - from .appliance_api import ApplianceApi - - -_LOGGER = logging.getLogger(__name__) - -DOOR_ERD_CODES = { - ErdCode.DOOR_STATUS -} -RAW_TEMPERATURE_ERD_CODES = { - ErdCode.LOWER_OVEN_RAW_TEMPERATURE, - ErdCode.LOWER_OVEN_USER_TEMP_OFFSET, - ErdCode.UPPER_OVEN_RAW_TEMPERATURE, - ErdCode.UPPER_OVEN_USER_TEMP_OFFSET, - ErdCode.CURRENT_TEMPERATURE, - ErdCode.TEMPERATURE_SETTING, -} -NONZERO_TEMPERATURE_ERD_CODES = { - ErdCode.HOT_WATER_SET_TEMP, - ErdCode.LOWER_OVEN_DISPLAY_TEMPERATURE, - ErdCode.LOWER_OVEN_PROBE_DISPLAY_TEMP, - ErdCode.UPPER_OVEN_DISPLAY_TEMPERATURE, - ErdCode.UPPER_OVEN_PROBE_DISPLAY_TEMP, -} -TEMPERATURE_ERD_CODES = RAW_TEMPERATURE_ERD_CODES.union(NONZERO_TEMPERATURE_ERD_CODES) -TIMER_ERD_CODES = { - ErdCode.LOWER_OVEN_ELAPSED_COOK_TIME, - ErdCode.LOWER_OVEN_KITCHEN_TIMER, - ErdCode.LOWER_OVEN_DELAY_TIME_REMAINING, - ErdCode.LOWER_OVEN_COOK_TIME_REMAINING, - ErdCode.LOWER_OVEN_ELAPSED_COOK_TIME, - ErdCode.ELAPSED_ON_TIME, - ErdCode.TIME_REMAINING, - ErdCode.UPPER_OVEN_ELAPSED_COOK_TIME, - ErdCode.UPPER_OVEN_KITCHEN_TIMER, - ErdCode.UPPER_OVEN_DELAY_TIME_REMAINING, - ErdCode.UPPER_OVEN_ELAPSED_COOK_TIME, - ErdCode.UPPER_OVEN_COOK_TIME_REMAINING, -} - - -def boolify_erd_value(erd_code: ErdCodeType, value: Any) -> Optional[bool]: - """ - Convert an erd property value to a bool - - :param erd_code: The ERD code for the property - :param value: The current value in its native format - :return: The value converted to a bool - """ - erd_code = translate_erd_code(erd_code) - if isinstance(value, ErdDoorStatus): - if value == ErdDoorStatus.NA: - return None - return value == ErdDoorStatus.OPEN - if value is None: - return None - return bool(value) - - -def stringify_erd_value(erd_code: ErdCodeType, value: Any, units: Optional[str] = None) -> Optional[str]: - """ - Convert an erd property value to a nice string - - :param erd_code: The ERD code for the property - :param value: The current value in its native format - :param units: Units to apply, if applicable - :return: The value converted to a string - """ - erd_code = translate_erd_code(erd_code) - - if isinstance(value, ErdOvenState): - return oven_display_state_to_str(value) - if isinstance(value, OvenCookSetting): - return oven_cook_setting_to_str(value, units) - if isinstance(value, FridgeDoorStatus): - return value.status - if isinstance(value, FridgeIceBucketStatus): - return bucket_status_to_str(value) - if isinstance(value, ErdFilterStatus): - return value.name.capitalize() - if isinstance(value, HotWaterStatus): - return hot_water_status_str(value) - if isinstance(value, ErdDoorStatus): - return door_status_to_str(value) - - if erd_code == ErdCode.CLOCK_TIME: - return value.strftime("%H:%M:%S") if value else None - if erd_code in RAW_TEMPERATURE_ERD_CODES: - return f"{value}" - if erd_code in NONZERO_TEMPERATURE_ERD_CODES: - return f"{value}" if value else "" - if erd_code in TIMER_ERD_CODES: - return str(value)[:-3] if value else "" - if erd_code == ErdCode.DOOR_STATUS: - return value.status - if value is None: - return None - return str(value) - - -def get_erd_units(erd_code: ErdCodeType, measurement_units: ErdMeasurementUnits): - """Get the units for a sensor.""" - erd_code = translate_erd_code(erd_code) - if not measurement_units: - return None - - if erd_code in TEMPERATURE_ERD_CODES or erd_code in {ErdCode.LOWER_OVEN_COOK_MODE, ErdCode.UPPER_OVEN_COOK_MODE}: - if measurement_units == ErdMeasurementUnits.METRIC: - return TEMP_CELSIUS - return TEMP_FAHRENHEIT - return None - - -def get_erd_icon(erd_code: ErdCodeType, value: Any = None) -> Optional[str]: - """Select an appropriate icon.""" - erd_code = translate_erd_code(erd_code) - if not isinstance(erd_code, ErdCode): - return None - if erd_code in TIMER_ERD_CODES: - return "mdi:timer-outline" - if erd_code in { - ErdCode.LOWER_OVEN_COOK_MODE, - ErdCode.LOWER_OVEN_CURRENT_STATE, - ErdCode.LOWER_OVEN_WARMING_DRAWER_STATE, - ErdCode.UPPER_OVEN_COOK_MODE, - ErdCode.UPPER_OVEN_CURRENT_STATE, - ErdCode.UPPER_OVEN_WARMING_DRAWER_STATE, - ErdCode.WARMING_DRAWER_STATE, - }: - return "mdi:stove" - if erd_code in { - ErdCode.TURBO_COOL_STATUS, - ErdCode.TURBO_FREEZE_STATUS, - }: - return "mdi:snowflake" - if erd_code == ErdCode.SABBATH_MODE: - return "mdi:judaism" - - # Let binary sensors assign their own. Might be worth passing - # the actual entity in if we want to do more of this. - if erd_code in DOOR_ERD_CODES and isinstance(value, str): - if "open" in value.lower(): - return "mdi:door-open" - return "mdi:door-closed" - - return None - - -class GeEntity: - """Base class for all GE Entities""" - should_poll = False - - def __init__(self, api: "ApplianceApi"): - self._api = api - self.hass = None # type: Optional[HomeAssistant] - - @property - def unique_id(self) -> str: - raise NotImplementedError - - @property - def api(self) -> "ApplianceApi": - return self._api - - @property - def device_info(self) -> Optional[Dict[str, Any]]: - return self.api.device_info - - @property - def serial_number(self): - return self.api.serial_number - - @property - def available(self) -> bool: - return self.appliance.available - - @property - def appliance(self) -> GeAppliance: - return self.api.appliance - - @property - def name(self) -> Optional[str]: - raise NotImplementedError - - -class GeErdEntity(GeEntity): - """Parent class for GE entities tied to a specific ERD""" - def __init__(self, api: "ApplianceApi", erd_code: ErdCodeType): - super().__init__(api) - self._erd_code = translate_erd_code(erd_code) - - @property - def erd_code(self) -> ErdCodeType: - return self._erd_code - - @property - def erd_string(self) -> str: - erd_code = self.erd_code - if isinstance(self.erd_code, ErdCode): - return erd_code.name - return erd_code - - @property - def name(self) -> Optional[str]: - erd_string = self.erd_string - return " ".join(erd_string.split("_")).title() - - @property - def unique_id(self) -> Optional[str]: - return f"{DOMAIN}_{self.serial_number}_{self.erd_string.lower()}" - - @property - def icon(self) -> Optional[str]: - return get_erd_icon(self.erd_code) diff --git a/ge_kitchen/erd_constants/__init__.py b/ge_kitchen/erd_constants/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/ge_kitchen/erd_constants/oven_constants.py b/ge_kitchen/erd_constants/oven_constants.py deleted file mode 100644 index ae1335e..0000000 --- a/ge_kitchen/erd_constants/oven_constants.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Constants for GE Oven states""" - -from gekitchen.erd_constants import ErdOvenState - -STATE_OVEN_BAKE = "Bake" -STATE_OVEN_BAKE_TWO_TEMP = "Bake (Two Temp.)" -STATE_OVEN_BAKED_GOODS = "Baked Goods" -STATE_OVEN_BROIL_HIGH = "Broil (High)" -STATE_OVEN_BROIL_LOW = "Broil (Low)" -STATE_OVEN_CONV_BAKE = "Convection Bake" -STATE_OVEN_CONV_BAKE_TWO_TEMP = "Convection Bake (Two Temp.)" -STATE_OVEN_CONV_BROIL_CRISP = "Convection Broil (Crisp)" -STATE_OVEN_CONV_BROIL_HIGH = "Convection Broil (High)" -STATE_OVEN_CONV_BROIL_LOW = "Convection Broil (Low)" -STATE_OVEN_CONV_BAKE_MULTI = "Convection Multi Bake" -STATE_OVEN_CONV_ROAST = "Convection Roast" -STATE_OVEN_CONV_ROAST_TWO_TEMP = "Convection Roast (Two Temp.)" -STATE_OVEN_DUAL_BROIL_HIGH = "Dual Broil (High)" -STATE_OVEN_DUAL_BROIL_LOW = "Dual Broil (Low)" -STATE_OVEN_DELAY = "Delayed Start" -STATE_OVEN_FROZEN_PIZZA = "Frozen Pizza" -STATE_OVEN_FROZEN_SNACKS = "Frozen Snacks" -STATE_OVEN_MULTI_BAKE = "Multi Bake" -STATE_OVEN_PREHEAT = "Preheat" -STATE_OVEN_PROBE = "Probe" -STATE_OVEN_PROOF = "Proof" -STATE_OVEN_OFF = "Off" -STATE_OVEN_SABBATH = "Sabbath Mode" -STATE_OVEN_SELF_CLEAN = "Self Clean" -STATE_OVEN_SPECIAL = "Special" -STATE_OVEN_STEAM_CLEAN = "Steam Clean" -STATE_OVEN_TIMED = "Timed" -STATE_OVEN_UNKNOWN = "Unknown" -STATE_OVEN_WARM = "Keep Warm" - -OVEN_DISPLAY_STATE_MAP = { - ErdOvenState.BAKE: STATE_OVEN_BAKE, - ErdOvenState.BAKE_PREHEAT: STATE_OVEN_PREHEAT, - ErdOvenState.BAKE_TWO_TEMP: STATE_OVEN_BAKE_TWO_TEMP, - ErdOvenState.BROIL_HIGH: STATE_OVEN_BROIL_HIGH, - ErdOvenState.BROIL_LOW: STATE_OVEN_BROIL_LOW, - ErdOvenState.CLEAN_COOL_DOWN: STATE_OVEN_SELF_CLEAN, - ErdOvenState.CLEAN_STAGE1: STATE_OVEN_SELF_CLEAN, - ErdOvenState.CLEAN_STAGE2: STATE_OVEN_SELF_CLEAN, - ErdOvenState.CONV_BAKE: STATE_OVEN_CONV_BAKE, - ErdOvenState.CONV_BAKE_PREHEAT: STATE_OVEN_PREHEAT, - ErdOvenState.CONV_BAKE_TWO_TEMP: STATE_OVEN_CONV_BAKE_TWO_TEMP, - ErdOvenState.CONV_BROIL_CRISP: STATE_OVEN_CONV_BROIL_CRISP, - ErdOvenState.CONV_BROIL_HIGH: STATE_OVEN_CONV_BROIL_HIGH, - ErdOvenState.CONV_BROIL_LOW: STATE_OVEN_CONV_BROIL_LOW, - ErdOvenState.CONV_MULTI_BAKE_PREHEAT: STATE_OVEN_PREHEAT, - ErdOvenState.CONV_MULTI_TWO_BAKE: STATE_OVEN_MULTI_BAKE, - ErdOvenState.CONV_MUTLI_BAKE: STATE_OVEN_MULTI_BAKE, - ErdOvenState.CONV_ROAST: STATE_OVEN_CONV_ROAST, - ErdOvenState.CONV_ROAST2: STATE_OVEN_CONV_ROAST_TWO_TEMP, - ErdOvenState.CONV_ROAST_BAKE_PREHEAT: STATE_OVEN_PREHEAT, - ErdOvenState.CUSTOM_CLEAN_STAGE2: STATE_OVEN_SELF_CLEAN, - ErdOvenState.DELAY: STATE_OVEN_DELAY, - ErdOvenState.NO_MODE: STATE_OVEN_OFF, - ErdOvenState.PROOF: STATE_OVEN_PROOF, - ErdOvenState.SABBATH: STATE_OVEN_SABBATH, - ErdOvenState.STEAM_CLEAN_STAGE2: STATE_OVEN_STEAM_CLEAN, - ErdOvenState.STEAM_COOL_DOWN: STATE_OVEN_STEAM_CLEAN, - ErdOvenState.WARM: STATE_OVEN_WARM, - ErdOvenState.OVEN_STATE_BAKE: STATE_OVEN_BAKE, - ErdOvenState.OVEN_STATE_BAKED_GOODS: STATE_OVEN_BAKED_GOODS, - ErdOvenState.OVEN_STATE_BROIL: STATE_OVEN_BROIL_HIGH, - ErdOvenState.OVEN_STATE_CONV_BAKE: STATE_OVEN_CONV_BAKE, - ErdOvenState.OVEN_STATE_CONV_BAKE_MULTI: STATE_OVEN_CONV_BAKE_MULTI, - ErdOvenState.OVEN_STATE_CONV_BROIL: STATE_OVEN_CONV_BROIL_HIGH, - ErdOvenState.OVEN_STATE_CONV_ROAST: STATE_OVEN_CONV_ROAST, - ErdOvenState.OVEN_STATE_DUAL_BROIL_HIGH: STATE_OVEN_DUAL_BROIL_HIGH, - ErdOvenState.OVEN_STATE_DUAL_BROIL_LOW: STATE_OVEN_DUAL_BROIL_LOW, - ErdOvenState.OVEN_STATE_DELAY_START: STATE_OVEN_DELAY, - ErdOvenState.OVEN_STATE_FROZEN_PIZZA: STATE_OVEN_FROZEN_PIZZA, - ErdOvenState.OVEN_STATE_FROZEN_PIZZA_MULTI: STATE_OVEN_FROZEN_PIZZA, - ErdOvenState.OVEN_STATE_FROZEN_SNACKS: STATE_OVEN_FROZEN_SNACKS, - ErdOvenState.OVEN_STATE_FROZEN_SNACKS_MULTI: STATE_OVEN_FROZEN_SNACKS, - ErdOvenState.OVEN_STATE_PROOF: STATE_OVEN_PROOF, - ErdOvenState.OVEN_STATE_SELF_CLEAN: STATE_OVEN_SELF_CLEAN, - ErdOvenState.OVEN_STATE_SPECIAL_X: STATE_OVEN_SPECIAL, - ErdOvenState.OVEN_STATE_STEAM_START: STATE_OVEN_STEAM_CLEAN, - ErdOvenState.OVEN_STATE_WARM: STATE_OVEN_WARM, - ErdOvenState.STATUS_DASH: STATE_OVEN_OFF, -} diff --git a/ge_kitchen/erd_string_utils.py b/ge_kitchen/erd_string_utils.py deleted file mode 100644 index b6d5dbf..0000000 --- a/ge_kitchen/erd_string_utils.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Utilities to make nice strings from ERD values.""" - -__all__ = ( - "hot_water_status_str", - "oven_display_state_to_str", - "oven_cook_setting_to_str", - "bucket_status_to_str", - "door_status_to_str", -) - -from typing import Optional - -from gekitchen.erd_types import OvenCookSetting, FridgeIceBucketStatus, HotWaterStatus -from gekitchen.erd_constants import ErdOvenState, ErdFullNotFull, ErdDoorStatus -from .erd_constants.oven_constants import ( - OVEN_DISPLAY_STATE_MAP, - STATE_OVEN_DELAY, - STATE_OVEN_PROBE, - STATE_OVEN_SABBATH, - STATE_OVEN_TIMED, - STATE_OVEN_UNKNOWN, -) - - -def oven_display_state_to_str(oven_state: ErdOvenState) -> str: - """Translate ErdOvenState values to a nice constant.""" - return OVEN_DISPLAY_STATE_MAP.get(oven_state, STATE_OVEN_UNKNOWN) - - -def oven_cook_setting_to_str(cook_setting: OvenCookSetting, units: str) -> str: - """Format OvenCookSetting values nicely.""" - cook_mode = cook_setting.cook_mode - cook_state = cook_mode.oven_state - temperature = cook_setting.temperature - - modifiers = [] - if cook_mode.timed: - modifiers.append(STATE_OVEN_TIMED) - if cook_mode.delayed: - modifiers.append(STATE_OVEN_DELAY) - if cook_mode.probe: - modifiers.append(STATE_OVEN_PROBE) - if cook_mode.sabbath: - modifiers.append(STATE_OVEN_SABBATH) - - temp_str = f" ({temperature}{units})" if temperature > 0 else "" - modifier_str = f" ({', '.join(modifiers)})" if modifiers else "" - display_state = oven_display_state_to_str(cook_state) - return f"{display_state}{temp_str}{modifier_str}" - - -def bucket_status_to_str(bucket_status: FridgeIceBucketStatus) -> str: - status = bucket_status.total_status - if status == ErdFullNotFull.FULL: - return "Full" - if status == ErdFullNotFull.NOT_FULL: - return "Not Full" - if status == ErdFullNotFull.NA: - return "NA" - - -def hot_water_status_str(water_status: HotWaterStatus) -> str: - raise NotImplementedError - - -def door_status_to_str(door_status: ErdDoorStatus) -> Optional[str]: - if door_status == ErdDoorStatus.NA: - return None - return door_status.name.title() diff --git a/ge_kitchen/exceptions.py b/ge_kitchen/exceptions.py deleted file mode 100644 index 1c837f5..0000000 --- a/ge_kitchen/exceptions.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Exceptions go here.""" - -from homeassistant import exceptions as ha_exc - - -class CannotConnect(ha_exc.HomeAssistantError): - """Error to indicate we cannot connect.""" - - -class AuthError(ha_exc.HomeAssistantError): - """Error to indicate authentication failure.""" diff --git a/ge_kitchen/manifest.json b/ge_kitchen/manifest.json deleted file mode 100644 index 6cf23c4..0000000 --- a/ge_kitchen/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "ge_kitchen", - "name": "GE Kitchen", - "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/ge_kitchen", - "requirements": ["gekitchen==0.2.19"], - "codeowners": ["@ajmarks"] -} diff --git a/ge_kitchen/sensor.py b/ge_kitchen/sensor.py deleted file mode 100644 index d020200..0000000 --- a/ge_kitchen/sensor.py +++ /dev/null @@ -1,123 +0,0 @@ -"""GE Kitchen Sensor Entities""" -import async_timeout -import logging -from typing import Optional, Callable, TYPE_CHECKING - -from gekitchen import ErdCodeType -from gekitchen.erd_constants import * - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import Entity - -from .const import DOMAIN -from .entities import ( - TEMPERATURE_ERD_CODES, - GeErdEntity, - get_erd_icon, - get_erd_units, - stringify_erd_value, -) - -if TYPE_CHECKING: - from .update_coordinator import GeKitchenUpdateCoordinator - from .appliance_api import ApplianceApi - - -_LOGGER = logging.getLogger(__name__) - - -class GeErdSensor(GeErdEntity, Entity): - """GE Entity for sensors""" - @property - def state(self) -> Optional[str]: - try: - value = self.appliance.get_erd_value(self.erd_code) - except KeyError: - return None - return stringify_erd_value(self.erd_code, value, self.units) - - @property - def measurement_system(self) -> Optional[ErdMeasurementUnits]: - return self.appliance.get_erd_value(ErdCode.TEMPERATURE_UNIT) - - @property - def units(self) -> Optional[str]: - return get_erd_units(self.erd_code, self.measurement_system) - - @property - def device_class(self) -> Optional[str]: - if self.erd_code in TEMPERATURE_ERD_CODES: - return DEVICE_CLASS_TEMPERATURE - return None - - @property - def icon(self) -> Optional[str]: - return get_erd_icon(self.erd_code, self.state) - - @property - def unit_of_measurement(self) -> Optional[str]: - if self.device_class == DEVICE_CLASS_TEMPERATURE: - return self.units - - -class GeErdPropertySensor(GeErdSensor): - """GE Entity for sensors""" - def __init__(self, api: "ApplianceApi", erd_code: ErdCodeType, erd_property: str): - super().__init__(api, erd_code) - self.erd_property = erd_property - - @property - def unique_id(self) -> Optional[str]: - return f"{super().unique_id}_{self.erd_property}" - - @property - def name(self) -> Optional[str]: - base_string = super().name - property_name = self.erd_property.replace("_", " ").title() - return f"{base_string} {property_name}" - - @property - def state(self) -> Optional[str]: - try: - value = getattr(self.appliance.get_erd_value(self.erd_code), self.erd_property) - except KeyError: - return None - return stringify_erd_value(self.erd_code, value, self.units) - - @property - def measurement_system(self) -> Optional[ErdMeasurementUnits]: - return self.appliance.get_erd_value(ErdCode.TEMPERATURE_UNIT) - - @property - def units(self) -> Optional[str]: - return get_erd_units(self.erd_code, self.measurement_system) - - @property - def device_class(self) -> Optional[str]: - if self.erd_code in TEMPERATURE_ERD_CODES: - return "temperature" - return None - - -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable): - """GE Kitchen sensors.""" - _LOGGER.debug('Adding GE Kitchen sensors') - coordinator: "GeKitchenUpdateCoordinator" = hass.data[DOMAIN][config_entry.entry_id] - - # This should be a NOP, but let's be safe - with async_timeout.timeout(20): - await coordinator.initialization_future - _LOGGER.debug('Coordinator init future finished') - - apis = list(coordinator.appliance_apis.values()) - _LOGGER.debug(f'Found {len(apis):d} appliance APIs') - entities = [ - entity - for api in apis - for entity in api.entities - if isinstance(entity, GeErdSensor) and entity.erd_code in api.appliance._property_cache - ] - _LOGGER.debug(f'Found {len(entities):d} sensors') - async_add_entities(entities) diff --git a/ge_kitchen/strings.json b/ge_kitchen/strings.json deleted file mode 100644 index fe1a212..0000000 --- a/ge_kitchen/strings.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" - } - } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]" - } - } -} diff --git a/ge_kitchen/switch.py b/ge_kitchen/switch.py deleted file mode 100644 index be5017d..0000000 --- a/ge_kitchen/switch.py +++ /dev/null @@ -1,58 +0,0 @@ -"""GE Kitchen Sensor Entities""" -import async_timeout -import logging -from typing import Callable, TYPE_CHECKING - -from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant - -from .binary_sensor import GeErdBinarySensor -from .const import DOMAIN - -if TYPE_CHECKING: - from .update_coordinator import GeKitchenUpdateCoordinator - -_LOGGER = logging.getLogger(__name__) - - -class GeErdSwitch(GeErdBinarySensor, SwitchEntity): - """Switches for boolean ERD codes.""" - device_class = "switch" - - @property - def is_on(self) -> bool: - """Return True if switch is on.""" - return bool(self.appliance.get_erd_value(self.erd_code)) - - async def async_turn_on(self, **kwargs): - """Turn the switch on.""" - _LOGGER.debug(f"Turning on {self.unique_id}") - await self.appliance.async_set_erd_value(self.erd_code, True) - - async def async_turn_off(self, **kwargs): - """Turn the switch off.""" - _LOGGER.debug(f"Turning on {self.unique_id}") - await self.appliance.async_set_erd_value(self.erd_code, False) - - -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable): - """GE Kitchen sensors.""" - _LOGGER.debug('Adding GE Kitchen switches') - coordinator: "GeKitchenUpdateCoordinator" = hass.data[DOMAIN][config_entry.entry_id] - - # This should be a NOP, but let's be safe - with async_timeout.timeout(20): - await coordinator.initialization_future - _LOGGER.debug('Coordinator init future finished') - - apis = list(coordinator.appliance_apis.values()) - _LOGGER.debug(f'Found {len(apis):d} appliance APIs') - entities = [ - entity - for api in apis - for entity in api.entities - if isinstance(entity, GeErdSwitch) and entity.erd_code in api.appliance._property_cache - ] - _LOGGER.debug(f'Found {len(entities):d} switches') - async_add_entities(entities) diff --git a/ge_kitchen/update_coordinator.py b/ge_kitchen/update_coordinator.py deleted file mode 100644 index 9d921e7..0000000 --- a/ge_kitchen/update_coordinator.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Data update coordinator for shark iq vacuums.""" - -import asyncio -import logging -from typing import Any, Dict, Iterable, Optional, Tuple - -from gekitchen import ( - EVENT_APPLIANCE_INITIAL_UPDATE, - EVENT_APPLIANCE_UPDATE_RECEIVED, - EVENT_CONNECTED, - EVENT_DISCONNECTED, - EVENT_GOT_APPLIANCE_LIST, - ErdCodeType, - GeAppliance, - GeWebsocketClient, -) - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from .const import DOMAIN, EVENT_ALL_APPLIANCES_READY, UPDATE_INTERVAL -from .appliance_api import ApplianceApi, get_appliance_api_type - -_LOGGER = logging.getLogger(__name__) - - -class GeKitchenUpdateCoordinator(DataUpdateCoordinator): - """Define a wrapper class to update Shark IQ data.""" - - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: - """Set up the SharkIqUpdateCoordinator class.""" - self._hass = hass - self._config_entry = config_entry - self._username = config_entry.data[CONF_USERNAME] - self._password = config_entry.data[CONF_PASSWORD] - self.client = None # type: Optional[GeWebsocketClient] - self._appliance_apis = {} # type: Dict[str, ApplianceApi] - - # Some record keeping to let us know when we can start generating entities - self._got_roster = False - self._init_done = False - self.initialization_future = asyncio.Future() - - super().__init__(hass, _LOGGER, name=DOMAIN) - - def create_ge_client(self, event_loop: Optional[asyncio.AbstractEventLoop]) -> GeWebsocketClient: - """ - Create a new GeClient object with some helpful callbacks. - - :param event_loop: Event loop - :return: GeWebsocketClient - """ - client = GeWebsocketClient(event_loop=event_loop, username=self._username, password=self._password) - client.add_event_handler(EVENT_APPLIANCE_INITIAL_UPDATE, self.on_device_initial_update) - client.add_event_handler(EVENT_APPLIANCE_UPDATE_RECEIVED, self.on_device_update) - client.add_event_handler(EVENT_GOT_APPLIANCE_LIST, self.on_appliance_list) - client.add_event_handler(EVENT_DISCONNECTED, self.on_disconnect) - client.add_event_handler(EVENT_CONNECTED, self.on_connect) - return client - - @property - def appliances(self) -> Iterable[GeAppliance]: - return self.client.appliances.values() - - @property - def appliance_apis(self) -> Dict[str, ApplianceApi]: - return self._appliance_apis - - def _get_appliance_api(self, appliance: GeAppliance) -> ApplianceApi: - api_type = get_appliance_api_type(appliance.appliance_type) - return api_type(self, appliance) - - def regenerate_appliance_apis(self): - """Regenerate the appliance_apis dictionary, adding elements as necessary.""" - for jid, appliance in self.client.appliances.keys(): - if jid not in self._appliance_apis: - self._appliance_apis[jid] = self._get_appliance_api(appliance) - - def maybe_add_appliance_api(self, appliance: GeAppliance): - mac_addr = appliance.mac_addr - if mac_addr not in self.appliance_apis: - _LOGGER.debug(f"Adding appliance api for appliance {mac_addr} ({appliance.appliance_type})") - api = self._get_appliance_api(appliance) - api.build_entities_list() - self.appliance_apis[mac_addr] = api - - async def get_client(self) -> GeWebsocketClient: - """Get a new GE Websocket client.""" - if self.client is not None: - await self.client.disconnect() - - loop = self._hass.loop - self.client = self.create_ge_client(event_loop=loop) - return self.client - - async def async_start_client(self): - """Start a new GeClient in the HASS event loop.""" - _LOGGER.debug('Running client') - client = await self.get_client() - - session = self._hass.helpers.aiohttp_client.async_get_clientsession() - await client.async_get_credentials(session) - fut = asyncio.ensure_future(client.async_run_client(), loop=self._hass.loop) - _LOGGER.debug('Client running') - return fut - - async def _kill_client(self): - """Kill the client. Leaving this in for testing purposes.""" - await asyncio.sleep(30) - _LOGGER.critical('Killing the connection. Popcorn time.') - await self.client.websocket.close() - - async def on_device_update(self, data: Tuple[GeAppliance, Dict[ErdCodeType, Any]]): - """Let HA know there's new state.""" - self.last_update_success = True - appliance, _ = data - try: - api = self.appliance_apis[appliance.mac_addr] - except KeyError: - return - for entity in api.entities: - _LOGGER.debug(f'Updating {entity} ({entity.unique_id}, {entity.entity_id})') - entity.async_write_ha_state() - - @property - def all_appliances_updated(self) -> bool: - """True if all appliances have had an initial update.""" - return all([a.initialized for a in self.appliances]) - - async def on_appliance_list(self, _): - """When we get an appliance list, mark it and maybe trigger all ready.""" - _LOGGER.debug('Got roster update') - self.last_update_success = True - if not self._got_roster: - self._got_roster = True - await asyncio.sleep(5) # After the initial roster update, wait a bit and hit go - await self.async_maybe_trigger_all_ready() - - async def on_device_initial_update(self, appliance: GeAppliance): - """When an appliance first becomes ready, let the system know and schedule periodic updates.""" - _LOGGER.debug(f'Got initial update for {appliance.mac_addr}') - self.last_update_success = True - self.maybe_add_appliance_api(appliance) - await self.async_maybe_trigger_all_ready() - _LOGGER.debug(f'Requesting updates for {appliance.mac_addr}') - while not self.client.websocket.closed and appliance.available: - await asyncio.sleep(UPDATE_INTERVAL) - await appliance.async_request_update() - _LOGGER.debug(f'No longer requesting updates for {appliance.mac_addr}') - - async def on_disconnect(self, _): - """Handle disconnection.""" - _LOGGER.debug("Disconnected. Attempting to reconnect.") - self.last_update_success = False - - flow_context = { - "source": "reauth", - "unique_id": self._config_entry.unique_id, - } - - matching_flows = [ - flow - for flow in self.hass.config_entries.flow.async_progress() - if flow["context"] == flow_context - ] - - if not matching_flows: - self.hass.async_create_task( - self.hass.config_entries.flow.async_init( - DOMAIN, context=flow_context, data=self._config_entry.data, - ) - ) - - async def on_connect(self, _): - """Set state upon connection.""" - self.last_update_success = True - - async def async_maybe_trigger_all_ready(self): - """See if we're all ready to go, and if so, let the games begin.""" - if self._init_done or self.initialization_future.done(): - # Been here, done this - return - if self._got_roster and self.all_appliances_updated: - _LOGGER.debug('Ready to go. Waiting 2 seconds and setting init future result.') - # The the flag and wait to prevent two different fun race conditions - self._init_done = True - await asyncio.sleep(2) - self.initialization_future.set_result(True) - await self.client.async_event(EVENT_ALL_APPLIANCES_READY, None) diff --git a/ge_kitchen/water_heater.py b/ge_kitchen/water_heater.py deleted file mode 100644 index 2d60c64..0000000 --- a/ge_kitchen/water_heater.py +++ /dev/null @@ -1,551 +0,0 @@ -"""GE Kitchen Sensor Entities""" -import abc -import async_timeout -from datetime import timedelta -import logging -from typing import Any, Callable, Dict, List, Optional, Set, Tuple, TYPE_CHECKING - -from bidict import bidict -from gekitchen import ( - ErdCode, - ErdDoorStatus, - ErdFilterStatus, - ErdFullNotFull, - ErdHotWaterStatus, - ErdMeasurementUnits, - ErdOnOff, - ErdOvenCookMode, - ErdPodStatus, - ErdPresent, - OVEN_COOK_MODE_MAP, -) -from gekitchen.erd_types import ( - FridgeDoorStatus, - FridgeSetPointLimits, - FridgeSetPoints, - FridgeIceBucketStatus, - HotWaterStatus, - IceMakerControlStatus, - OvenCookMode, - OvenCookSetting, -) - -from homeassistant.components.water_heater import ( - SUPPORT_OPERATION_MODE, - SUPPORT_TARGET_TEMPERATURE, - WaterHeaterEntity, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT -from homeassistant.core import HomeAssistant - -from .entities import GeEntity, stringify_erd_value -from .const import DOMAIN - -if TYPE_CHECKING: - from .appliance_api import ApplianceApi - from .update_coordinator import GeKitchenUpdateCoordinator - -_LOGGER = logging.getLogger(__name__) - -ATTR_DOOR_STATUS = "door_status" -GE_FRIDGE_SUPPORT = (SUPPORT_OPERATION_MODE | SUPPORT_TARGET_TEMPERATURE) -HEATER_TYPE_FRIDGE = "fridge" -HEATER_TYPE_FREEZER = "freezer" - -# Fridge/Freezer -OP_MODE_K_CUP = "K-Cup Brewing" -OP_MODE_NORMAL = "Normal" -OP_MODE_SABBATH = "Sabbath Mode" -OP_MODE_TURBO_COOL = "Turbo Cool" -OP_MODE_TURBO_FREEZE = "Turbo Freeze" - -# Oven -OP_MODE_OFF = "Off" -OP_MODE_BAKE = "Bake" -OP_MODE_CONVMULTIBAKE = "Conv. Multi-Bake" -OP_MODE_CONVBAKE = "Convection Bake" -OP_MODE_CONVROAST = "Convection Roast" -OP_MODE_COOK_UNK = "Unknown" - -UPPER_OVEN = "UPPER_OVEN" -LOWER_OVEN = "LOWER_OVEN" - -COOK_MODE_OP_MAP = bidict({ - ErdOvenCookMode.NOMODE: OP_MODE_OFF, - ErdOvenCookMode.CONVMULTIBAKE_NOOPTION: OP_MODE_CONVMULTIBAKE, - ErdOvenCookMode.CONVBAKE_NOOPTION: OP_MODE_CONVBAKE, - ErdOvenCookMode.CONVROAST_NOOPTION: OP_MODE_CONVROAST, - ErdOvenCookMode.BAKE_NOOPTION: OP_MODE_BAKE, -}) - - -class GeAbstractFridgeEntity(GeEntity, WaterHeaterEntity, metaclass=abc.ABCMeta): - """Mock a fridge or freezer as a water heater.""" - - @property - def heater_type(self) -> str: - raise NotImplementedError - - @property - def turbo_erd_code(self) -> str: - raise NotImplementedError - - @property - def turbo_mode(self) -> str: - raise NotImplementedError - - @property - def operation_list(self) -> List[str]: - return [OP_MODE_NORMAL, OP_MODE_SABBATH, self.turbo_mode] - - @property - def unique_id(self) -> str: - return f"{self.serial_number}-{self.heater_type}" - - @property - def name(self) -> Optional[str]: - return f"GE {self.heater_type.title()} {self.serial_number}" - - @property - def temperature_unit(self): - measurement_system = self.appliance.get_erd_value(ErdCode.TEMPERATURE_UNIT) - if measurement_system == ErdMeasurementUnits.METRIC: - return TEMP_CELSIUS - return TEMP_FAHRENHEIT - - @property - def target_temps(self) -> FridgeSetPoints: - """Get the current temperature settings tuple.""" - return self.appliance.get_erd_value(ErdCode.TEMPERATURE_SETTING) - - @property - def target_temperature(self) -> int: - """Return the temperature we try to reach.""" - return getattr(self.target_temps, self.heater_type) - - @property - def current_temperature(self) -> int: - """Return the current temperature.""" - current_temps = self.appliance.get_erd_value(ErdCode.CURRENT_TEMPERATURE) - current_temp = getattr(current_temps, self.heater_type) - if current_temp is None: - _LOGGER.exception(f"{self.name} has None for current_temperature (available: {self.available})!") - return current_temp - - async def async_set_temperature(self, **kwargs): - target_temp = kwargs.get(ATTR_TEMPERATURE) - if target_temp is None: - return - if not self.min_temp <= target_temp <= self.max_temp: - raise ValueError("Tried to set temperature out of device range") - - if self.heater_type == HEATER_TYPE_FRIDGE: - new_temp = FridgeSetPoints(fridge=target_temp, freezer=self.target_temps.freezer) - elif self.heater_type == HEATER_TYPE_FREEZER: - new_temp = FridgeSetPoints(fridge=self.target_temps.fridge, freezer=target_temp) - else: - raise ValueError("Invalid heater_type") - - await self.appliance.async_set_erd_value(ErdCode.TEMPERATURE_SETTING, new_temp) - - @property - def supported_features(self): - return GE_FRIDGE_SUPPORT - - @property - def setpoint_limits(self) -> FridgeSetPointLimits: - return self.appliance.get_erd_value(ErdCode.SETPOINT_LIMITS) - - @property - def min_temp(self): - """Return the minimum temperature.""" - return getattr(self.setpoint_limits, f"{self.heater_type}_min") - - @property - def max_temp(self): - """Return the maximum temperature.""" - return getattr(self.setpoint_limits, f"{self.heater_type}_max") - - @property - def current_operation(self) -> str: - """Get ther current operation mode.""" - if self.appliance.get_erd_value(ErdCode.SABBATH_MODE): - return OP_MODE_SABBATH - if self.appliance.get_erd_value(self.turbo_erd_code): - return self.turbo_mode - return OP_MODE_NORMAL - - async def async_set_sabbath_mode(self, sabbath_on: bool = True): - """Set sabbath mode if it's changed""" - if self.appliance.get_erd_value(ErdCode.SABBATH_MODE) == sabbath_on: - return - await self.appliance.async_set_erd_value(ErdCode.SABBATH_MODE, sabbath_on) - - async def async_set_operation_mode(self, operation_mode): - """Set the operation mode.""" - if operation_mode not in self.operation_list: - raise ValueError("Invalid operation mode") - if operation_mode == self.current_operation: - return - sabbath_mode = operation_mode == OP_MODE_SABBATH - await self.async_set_sabbath_mode(sabbath_mode) - if not sabbath_mode: - await self.appliance.async_set_erd_value(self.turbo_erd_code, operation_mode == self.turbo_mode) - - @property - def door_status(self) -> FridgeDoorStatus: - """Shorthand to get door status.""" - return self.appliance.get_erd_value(ErdCode.DOOR_STATUS) - - @property - def ice_maker_state_attrs(self) -> Dict[str, Any]: - """Get state attributes for the ice maker, if applicable.""" - data = {} - - erd_val: FridgeIceBucketStatus = self.appliance.get_erd_value(ErdCode.ICE_MAKER_BUCKET_STATUS) - ice_bucket_status = getattr(erd_val, f"state_full_{self.heater_type}") - if ice_bucket_status != ErdFullNotFull.NA: - data["ice_bucket"] = ice_bucket_status.name.replace("_", " ").title() - - erd_val: IceMakerControlStatus = self.appliance.get_erd_value(ErdCode.ICE_MAKER_CONTROL) - ice_control_status = getattr(erd_val, f"status_{self.heater_type}") - if ice_control_status != ErdOnOff.NA: - data["ice_maker"] = ice_control_status.name.replace("_", " ").lower() - - return data - - @property - def door_state_attrs(self) -> Dict[str, Any]: - """Get state attributes for the doors.""" - return {} - - @property - def other_state_attrs(self) -> Dict[str, Any]: - """State attributes to be optionally overridden in subclasses.""" - return {} - - @property - def device_state_attributes(self) -> Dict[str, Any]: - door_attrs = self.door_state_attrs - ice_maker_attrs = self.ice_maker_state_attrs - other_attrs = self.other_state_attrs - return {**door_attrs, **ice_maker_attrs, **other_attrs} - - -class GeFridgeEntity(GeAbstractFridgeEntity): - heater_type = HEATER_TYPE_FRIDGE - turbo_erd_code = ErdCode.TURBO_COOL_STATUS - turbo_mode = OP_MODE_TURBO_COOL - icon = "mdi:fridge-bottom" - - @property - def available(self) -> bool: - available = super().available - if not available: - app = self.appliance - _LOGGER.critical(f"{self.name} unavailable. Appliance info: Availaible - {app._available} and Init - {app.initialized}") - return available - - @property - def other_state_attrs(self) -> Dict[str, Any]: - """Water filter state.""" - filter_status: ErdFilterStatus = self.appliance.get_erd_value(ErdCode.WATER_FILTER_STATUS) - if filter_status == ErdFilterStatus.NA: - return {} - return {"water_filter_status": filter_status.name.replace("_", " ").title()} - - @property - def door_state_attrs(self) -> Dict[str, Any]: - """Get state attributes for the doors.""" - data = {} - door_status = self.door_status - if not door_status: - return {} - door_right = door_status.fridge_right - door_left = door_status.fridge_left - drawer = door_status.drawer - - if door_right and door_right != ErdDoorStatus.NA: - data["right_door"] = door_status.fridge_right.name.title() - if door_left and door_left != ErdDoorStatus.NA: - data["left_door"] = door_status.fridge_left.name.title() - if drawer and drawer != ErdDoorStatus.NA: - data["drawer"] = door_status.fridge_left.name.title() - - if data: - all_closed = all(v == "Closed" for v in data.values()) - data[ATTR_DOOR_STATUS] = "Closed" if all_closed else "Open" - - return data - - -class GeFreezerEntity(GeAbstractFridgeEntity): - """A freezer is basically a fridge.""" - - heater_type = HEATER_TYPE_FREEZER - turbo_erd_code = ErdCode.TURBO_FREEZE_STATUS - turbo_mode = OP_MODE_TURBO_FREEZE - icon = "mdi:fridge-top" - - @property - def door_state_attrs(self) -> Optional[Dict[str, Any]]: - door_status = self.door_status.freezer - if door_status and door_status != ErdDoorStatus.NA: - return {ATTR_DOOR_STATUS: door_status.name.title()} - return {} - - -class GeFridgeWaterHeater(GeEntity, WaterHeaterEntity): - """Entity for in-fridge water heaters""" - - # These values are from FridgeHotWaterFragment.smali in the android app - min_temp = 90 - max_temp = 185 - - @property - def hot_water_status(self) -> HotWaterStatus: - """Access the main status value conveniently.""" - return self.appliance.get_erd_value(ErdCode.HOT_WATER_STATUS) - - @property - def unique_id(self) -> str: - """Make a unique id.""" - return f"{self.serial_number}-fridge-hot-water" - - @property - def name(self) -> Optional[str]: - """Name it reasonably.""" - return f"GE Fridge Water Heater {self.serial_number}" - - @property - def temperature_unit(self): - """Select the appropriate temperature unit.""" - measurement_system = self.appliance.get_erd_value(ErdCode.TEMPERATURE_UNIT) - if measurement_system == ErdMeasurementUnits.METRIC: - return TEMP_CELSIUS - return TEMP_FAHRENHEIT - - @property - def supports_k_cups(self) -> bool: - """Return True if the device supports k-cup brewing.""" - status = self.hot_water_status - return status.pod_status != ErdPodStatus.NA and status.brew_module != ErdPresent.NA - - @property - def operation_list(self) -> List[str]: - """Supported Operations List""" - ops_list = [OP_MODE_NORMAL, OP_MODE_SABBATH] - if self.supports_k_cups: - ops_list.append(OP_MODE_K_CUP) - return ops_list - - async def async_set_temperature(self, **kwargs): - pass - - async def async_set_operation_mode(self, operation_mode): - pass - - @property - def supported_features(self): - pass - - @property - def current_operation(self) -> str: - """Get the current operation mode.""" - if self.appliance.get_erd_value(ErdCode.SABBATH_MODE): - return OP_MODE_SABBATH - return OP_MODE_NORMAL - - @property - def current_temperature(self) -> Optional[int]: - """Return the current temperature.""" - return self.hot_water_status.current_temp - - -class GeOvenHeaterEntity(GeEntity, WaterHeaterEntity): - """Water Heater entity for ovens""" - - icon = "mdi:stove" - - def __init__(self, api: "ApplianceApi", oven_select: str = UPPER_OVEN, two_cavity: bool = False): - if oven_select not in (UPPER_OVEN, LOWER_OVEN): - raise ValueError(f"Invalid `oven_select` value ({oven_select})") - - self._oven_select = oven_select - self._two_cavity = two_cavity - super().__init__(api) - - @property - def supported_features(self): - return GE_FRIDGE_SUPPORT - - @property - def unique_id(self) -> str: - return f"{self.serial_number}-{self.oven_select.lower()}" - - @property - def name(self) -> Optional[str]: - if self._two_cavity: - oven_title = self.oven_select.replace("_", " ").title() - else: - oven_title = "Oven" - - return f"GE {oven_title}" - - @property - def temperature_unit(self): - measurement_system = self.appliance.get_erd_value(ErdCode.TEMPERATURE_UNIT) - if measurement_system == ErdMeasurementUnits.METRIC: - return TEMP_CELSIUS - return TEMP_FAHRENHEIT - - @property - def oven_select(self) -> str: - return self._oven_select - - def get_erd_code(self, suffix: str) -> ErdCode: - """Return the appropriate ERD code for this oven_select""" - return ErdCode[f"{self.oven_select}_{suffix}"] - - @property - def current_temperature(self) -> Optional[int]: - current_temp = self.get_erd_value("DISPLAY_TEMPERATURE") - if current_temp: - return current_temp - return self.get_erd_value("RAW_TEMPERATURE") - - @property - def current_operation(self) -> Optional[str]: - cook_setting = self.current_cook_setting - cook_mode = cook_setting.cook_mode - # TODO: simplify this lookup nonsense somehow - current_state = OVEN_COOK_MODE_MAP.inverse[cook_mode] - try: - return COOK_MODE_OP_MAP[current_state] - except KeyError: - _LOGGER.debug(f"Unable to map {current_state} to an operation mode") - return OP_MODE_COOK_UNK - - @property - def operation_list(self) -> List[str]: - erd_code = self.get_erd_code("AVAILABLE_COOK_MODES") - cook_modes: Set[ErdOvenCookMode] = self.appliance.get_erd_value(erd_code) - op_modes = [o for o in (COOK_MODE_OP_MAP[c] for c in cook_modes) if o] - op_modes = [OP_MODE_OFF] + op_modes - return op_modes - - @property - def current_cook_setting(self) -> OvenCookSetting: - """Get the current cook mode.""" - erd_code = self.get_erd_code("COOK_MODE") - return self.appliance.get_erd_value(erd_code) - - @property - def target_temperature(self) -> Optional[int]: - """Return the temperature we try to reach.""" - cook_mode = self.current_cook_setting - if cook_mode.temperature: - return cook_mode.temperature - return None - - @property - def min_temp(self) -> int: - """Return the minimum temperature.""" - min_temp, _ = self.appliance.get_erd_value(ErdCode.OVEN_MODE_MIN_MAX_TEMP) - return min_temp - - @property - def max_temp(self) -> int: - """Return the maximum temperature.""" - _, max_temp = self.appliance.get_erd_value(ErdCode.OVEN_MODE_MIN_MAX_TEMP) - return max_temp - - async def async_set_operation_mode(self, operation_mode: str): - """Set the operation mode.""" - - erd_cook_mode = COOK_MODE_OP_MAP.inverse[operation_mode] - # Pick a temperature to set. If there's not one already set, default to - # good old 350F. - if operation_mode == OP_MODE_OFF: - target_temp = 0 - elif self.target_temperature: - target_temp = self.target_temperature - elif self.temperature_unit == TEMP_FAHRENHEIT: - target_temp = 350 - else: - target_temp = 177 - - new_cook_mode = OvenCookSetting(OVEN_COOK_MODE_MAP[erd_cook_mode], target_temp) - erd_code = self.get_erd_code("COOK_MODE") - await self.appliance.async_set_erd_value(erd_code, new_cook_mode) - - async def async_set_temperature(self, **kwargs): - """Set the cook temperature""" - target_temp = kwargs.get(ATTR_TEMPERATURE) - if target_temp is None: - return - - current_op = self.current_operation - if current_op != OP_MODE_OFF: - erd_cook_mode = COOK_MODE_OP_MAP.inverse[current_op] - else: - erd_cook_mode = ErdOvenCookMode.BAKE_NOOPTION - - new_cook_mode = OvenCookSetting(OVEN_COOK_MODE_MAP[erd_cook_mode], target_temp) - erd_code = self.get_erd_code("COOK_MODE") - await self.appliance.async_set_erd_value(erd_code, new_cook_mode) - - def get_erd_value(self, suffix: str) -> Any: - erd_code = self.get_erd_code(suffix) - return self.appliance.get_erd_value(erd_code) - - @property - def display_state(self) -> Optional[str]: - erd_code = self.get_erd_code("CURRENT_STATE") - erd_value = self.appliance.get_erd_value(erd_code) - return stringify_erd_value(erd_code, erd_value, self.temperature_unit) - - @property - def device_state_attributes(self) -> Optional[Dict[str, Any]]: - probe_present = self.get_erd_value("PROBE_PRESENT") - data = { - "display_state": self.display_state, - "probe_present": probe_present, - "raw_temperature": self.get_erd_value("RAW_TEMPERATURE"), - } - if probe_present: - data["probe_temperature"] = self.get_erd_value("PROBE_DISPLAY_TEMP") - elapsed_time = self.get_erd_value("ELAPSED_COOK_TIME") - cook_time_left = self.get_erd_value("COOK_TIME_REMAINING") - kitchen_timer = self.get_erd_value("KITCHEN_TIMER") - delay_time = self.get_erd_value("DELAY_TIME_REMAINING") - if elapsed_time: - data["cook_time_elapsed"] = str(elapsed_time) - if cook_time_left: - data["cook_time_left"] = str(cook_time_left) - if kitchen_timer: - data["cook_time_remaining"] = str(kitchen_timer) - if delay_time: - data["delay_time_remaining"] = str(delay_time) - return data - - -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable): - """GE Kitchen sensors.""" - _LOGGER.debug('Adding GE "Water Heaters"') - coordinator: "GeKitchenUpdateCoordinator" = hass.data[DOMAIN][config_entry.entry_id] - - # This should be a NOP, but let's be safe - with async_timeout.timeout(20): - await coordinator.initialization_future - _LOGGER.debug('Coordinator init future finished') - - apis = list(coordinator.appliance_apis.values()) - _LOGGER.debug(f'Found {len(apis):d} appliance APIs') - entities = [ - entity for api in apis for entity in api.entities - if isinstance(entity, WaterHeaterEntity) - ] - _LOGGER.debug(f'Found {len(entities):d} "water heaters"') - async_add_entities(entities) diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..1d7862f --- /dev/null +++ b/hacs.json @@ -0,0 +1,6 @@ +{ + "name": "GE Home (SmartHQ)", + "homeassistant": "2024.9.0", + "domains": ["binary_sensor", "sensor", "switch", "water_heater", "select", "button", "climate", "light", "number"], + "iot_class": "Cloud Polling" +} diff --git a/img/ac_controls.png b/img/ac_controls.png new file mode 100644 index 0000000..ee1e2a5 Binary files /dev/null and b/img/ac_controls.png differ diff --git a/img/appliance_entities.png b/img/appliance_entities.png index 5f29203..fd15829 100644 Binary files a/img/appliance_entities.png and b/img/appliance_entities.png differ diff --git a/img/fridge_control.png b/img/fridge_control.png index cd6c87e..2923429 100644 Binary files a/img/fridge_control.png and b/img/fridge_control.png differ diff --git a/img/fridge_controls_dark.png b/img/fridge_controls_dark.png index c6f52aa..fefda41 100644 Binary files a/img/fridge_controls_dark.png and b/img/fridge_controls_dark.png differ diff --git a/img/oven_controls.png b/img/oven_controls.png index 5c5b396..c8e6a8e 100644 Binary files a/img/oven_controls.png and b/img/oven_controls.png differ diff --git a/img/shark_vacuum_card.png b/img/shark_vacuum_card.png deleted file mode 100644 index 558c1d1..0000000 Binary files a/img/shark_vacuum_card.png and /dev/null differ diff --git a/img/shark_vacuum_control.png b/img/shark_vacuum_control.png deleted file mode 100644 index 0d41446..0000000 Binary files a/img/shark_vacuum_control.png and /dev/null differ diff --git a/info.md b/info.md new file mode 100644 index 0000000..22b839d --- /dev/null +++ b/info.md @@ -0,0 +1,277 @@ +# GE Home Appliances (SmartHQ) + +Integration for GE WiFi-enabled appliances into Home Assistant. This integration currently support the following devices: + +- Fridge +- Oven +- Dishwasher / F&P Dual Dishwasher +- Laundry (Washer/Dryer) +- Whole Home Water Filter +- Whole Home Water Softener +- Whole Home Water Heater +- A/C (Portable, Split, Window) +- Range Hood +- Advantium +- Microwave +- Opal Ice Maker +- Coffee Maker / Espresso Maker +- Beverage Center + +**Forked from Andrew Mark's [repository](https://github.com/ajmarks/ha_components).** + +## Updates + +Unfortunately, I'm pretty much at the end of what I can do without assistance from others with these devices that can help provide logs. I'll do what I can to make updates if there's something broken, but I am not really able to add new functionality if I can't get a little help to do so. + +## Home Assistant UI Examples +Entities card: + +![Entities](https://raw.githubusercontent.com/simbaja/ha_components/master/img/appliance_entities.png) + +Fridge Controls: + +![Fridge controls](https://raw.githubusercontent.com/simbaja/ha_components/master/img/fridge_control.png) + +Oven Controls: + +![Fridge controls](https://raw.githubusercontent.com/simbaja/ha_components/master/img/oven_controls.png) + +A/C Controls: + +![A/C controls](https://raw.githubusercontent.com/simbaja/ha_components/master/img/ac_controls.png) + + +{% if installed %} +### Changes as compared to your installed version: + +#### Breaking Changes + +{% if version_installed.split('.') | map('int') < '2025.2.0'.split('.') | map('int') %} +- Changed dishwasher pods to number +- Removed outdated laundry status sensor +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.15'.split('.') | map('int') %} +- Some enums changed names/values and may need updates to client code +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.6'.split('.') | map('int') %} +- Requires HA version 2022.12.0 or later +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.0'.split('.') | map('int') %} +- Requires HA version 2021.12.0 or later +- Enabled authentication to both US and EU regions (may require re-auth) +- Changed the sensors to use native value/uom +- Changed the temperatures to always be natively fahrenheit (API appears to always use this system) (@vignatyuk) +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.4.0'.split('.') | map('int') %} +- Laundry support changes will cause entity names to be different, you will need to fix in HA (uninstall, reboot, delete leftover entitites, install, reboot) +{% endif %} + +#### Changes + +{% if version_installed.split('.') | map('int') < '2025.5.0'.split('.') | map('int') %} +- Improved documentation around terms of acceptance +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.5.0'.split('.') | map('int') %} +- Added logic to prevent multiple configurations of the same GE account +{% endif %} + +#### Features + +{% if version_installed.split('.') | map('int') < '2025.5.0'.split('.') | map('int') %} +- Added boost/active states for water heaters +{% endif %} + +{% if version_installed.split('.') | map('int') < '2025.2.0'.split('.') | map('int') %} +- Added under counter ice maker controls and sensors +- Changed versioning scheme for integration +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.15'.split('.') | map('int') %} +- Improved Support for Laundry +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.9'.split('.') | map('int') %} +- Added additional fridge controls (#200) +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.8'.split('.') | map('int') %} +- Added Dehumidifier (#114) +- Added oven drawer sensors +- Added oven current state sensors (#175) +- Added descriptors to manifest (#181) +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.7'.split('.') | map('int') %} +- Added OIM descaling sensor (#154) +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.6'.split('.') | map('int') %} +- Modified dishwasher to include new functionality (@NickWaterton) +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.5'.split('.') | map('int') %} +- Added beverage cooler support (@kksligh) +- Added dual dishwasher support (@jkili) +- Added initial espresso maker support (@datagen24) +- Added whole home water heater support (@seantibor) +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.0'.split('.') | map('int') %} +- Initial support for built-in air conditioners (@DaveZheng) +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.0'.split('.') | map('int') %} +- Initial support for Water Softeners (@npentell, @drjeff) +- Initial support for Opal Ice Makers (@mbcomer, @knobunc) +- Initial support for Microwaves (@mbcomer, @mnestor) +- Initial support for Coffee Makers (@alexanv1) +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.5.0'.split('.') | map('int') %} +- Support for Oven Hood units (@digitalbites) +- Added extended mode support for ovens +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.4.3'.split('.') | map('int') %} +- Support for Portable, Split, and Window AC units (@swcrawford1, @mbrentrowe, @RobertusIT, @luddystefenson) +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.4.0'.split('.') | map('int') %} +- Implemented Laundry Support (@warrenrees, @ssindsd) +- Implemented Water Filter Support (@bendavis, @tumtumsback, @rgabrielson11) +- Implemented Initial Advantium Support (@ssinsd) +- Additional authentication error handling (@rgabrielson11) +- Additional dishwasher functionality (@ssinsd) +- Introduced new select entity (@bendavis) +- Integrated new version of SDK +{% endif %} + +#### Bugfixes + +{% if version_installed.split('.') | map('int') < '2025.5.0'.split('.') | map('int') %} +- Fixed helper deprecations +{% endif %} + + +{% if version_installed.split('.') | map('int') < '2025.2.1'.split('.') | map('int') %} +- Fix for #339 +{% endif %} + + +{% if version_installed.split('.') | map('int') < '2025.2.0'.split('.') | map('int') %} +- Updated SDK to fix broken types +{% endif %} + + +{% if version_installed.split('.') | map('int') < '0.6.14'.split('.') | map('int') %} +- Bugfix: Error checking socket status [#304] +- Bugfix: Error with setup [#301] +- Bugfix: Logger deprecations +{% endif %} + + +{% if version_installed.split('.') | map('int') < '0.6.13'.split('.') | map('int') %} +- Bugfix: Deprecations [#290] [#297] +{% endif %} + + +{% if version_installed.split('.') | map('int') < '0.6.12'.split('.') | map('int') %} +- Bugfix: Deprecations [#271] +{% endif %} + + +{% if version_installed.split('.') | map('int') < '0.6.13'.split('.') | map('int') %} +- Bugfix: Deprecations [#290] [#297] +{% endif %} + + +{% if version_installed.split('.') | map('int') < '0.6.12'.split('.') | map('int') %} +- Bugfix: Deprecations [#271] +{% endif %} + + +{% if version_installed.split('.') | map('int') < '0.6.11'.split('.') | map('int') %} +- Bugfix: Fixed convertable drawer issue (#243) +- Bugfix: Updated app types to include electric cooktops (#252) +- Bugfix: Updated clientsession to remove deprecation (#253) +- Bugfix: Fixed error strings +- Bugfix: Updated climate support for new flags introduced in 2024.2.0 +{% endif %} + + +{% if version_installed.split('.') | map('int') < '0.6.10'.split('.') | map('int') %} +- Bugfix: Removed additional deprecated constants (#229) +- Bugfix: Fixed issue with climate entities (#228) +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.9'.split('.') | map('int') %} +- Bugfix: Additional auth stability improvements (#215, #211) +- Bugfix: Removed deprecated constants (#218) +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.8'.split('.') | map('int') %} +- Bugfix: Fixed issue with oven lights (#174) +- Bugfix: Fixed issues with dual dishwasher (#161) +- Bugfix: Fixed disconnection issue (#169) +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.7'.split('.') | map('int') %} +- Bugfix: fixed issues with dishwasher (#155) +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.6'.split('.') | map('int') %} +- Fixed region issues after setup (#130) +- Updated the temperature conversion (#137) +- UoM updates (#138) +- Fixed oven typo (#149) +- Updated light control (#144) +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.3'.split('.') | map('int') %} +- Updated detection of invalid serial numbers (#89) +- Updated implementation of number entities to fix deprecation warnings (#85) +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.2'.split('.') | map('int') %} +- Fixed issue with water heater naming when no serial is present +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.1'.split('.') | map('int') %} +- Fixed issue with water filter life sensor (@rgabrielson11) +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.0'.split('.') | map('int') %} +- Updated deprecated icons (@mjmeli, @schmittx) +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.5.0'.split('.') | map('int') %} +- Advantium fixes (@willhayslett) +- Fixed device info when serial not present (@Xe138) +- Fixed issue with ovens when raw temperature not available (@chadohalloran) +- Fixed issue where Split A/C temperature sensors report UOM incorrectly (@RobertusIT) +- Added convertable drawer mode, proximity light, and interior lights to fridge (@groto27, @elwing00) +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.4.3'.split('.') | map('int') %} +- Bug fixes for laundry (@steveredden, @sweichbr) +- Fixed startup issue when encountering an unknown unit type(@chansearrington, @opie546) +- Fixed interpretation of A/C demand response power (@garulf) +- Fixed issues with updating disabled entities (@willhayslett) +- Advantium fixes (@willhayslett) +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.4.1'.split('.') | map('int') %} +- Fixed an issue with dryer entities causing an error in HA (@steveredden) +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.4.0'.split('.') | map('int') %} +- Bug fixes for ovens (@TKpizza) +- Miscellaneous entity bug fixes/refinements +{% endif %} + +{% endif %}