diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e5e36d2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,196 @@ + +# GE Home Appliances (SmartHQ) Changelog + +## 2026.2.0 + +- Feature: Added DRY mode to HVAC options and mappings [#441] +- Feature: Added GeWasherCycleButton to WasherDryerApi [#462] +- Feature: Added DishDrawer User Setting wifi_enabled (read only) [#463] +- Change: Changed mode names for Haier water heaters [#442] +- Change: Made LAUNDRY_MACHINE_STATE diagnostic on all appliances [#447] +- Bugfix: Cooktop Sensor fixes [#440, #454] +- Bugfix: Persist ApplianceApis on reconnect to prevent duplicate entities [#464] + +## 2025.12.0 + +- Bugfix: Climate heat mode setting [#433, #435] +- Feature: Changed time-related entities to be durations instead of text [#312] + +## 2025.11.0 + +- Breaking: changed name of some SAC/WAC entities to have a AC prefix +- Feature: Added heat mode for Window ACs +- Feature: Added support for Advantium +- Feature: Brand inference and stale device cleanup +- Feature: Added support for new hoods that require state/control ERDs +- Feature: Added entity categorization +- Feature: Added dishwasher remote commands +- Change: Refactored code internally to improve reliability +- Change: Cleaned up initialization and config flow +- Bugfix: Fixed temperature unit for ovens [#248, #328, #344] +- Bugfix: Water heater mode setting [#107] + +## 2025.7.0 + +- Change: Silenced string prep warning [#386] (@derekcentrico) +- Feature: Enabled Washer/Dryer remote start [#369] (@derekcentrico) +- Feature: Enabled K-cup refrigerator functionality [#101] (@derekcentrico) + +## 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 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..f200bd0 --- /dev/null +++ b/custom_components/ge_home/__init__.py @@ -0,0 +1,123 @@ +"""The ge_home integration.""" + +import logging +import voluptuous as vol +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_USERNAME, CONF_REGION, EVENT_HOMEASSISTANT_STOP +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) -> bool: + """Migrate old config entry to the latest schema.""" + + old_version: int = config_entry.version + data: dict[str, Any] = dict(config_entry.data) + + # --- Migrate from version 1 to 2 + if old_version == 1: + _LOGGER.debug(f"GE Home: Migrating entry {config_entry.entry_id} from v1 to v2") + + # Apply default US region if missing + data[CONF_REGION] = "US" + + hass.config_entries.async_update_entry( + config_entry, + data=data, + version=2, + ) + + _LOGGER.info(f"GE Home: Migration of entry {config_entry.entry_id} to v2 successful") + old_version = 2 + + # --- Migrate any version 2 to 3 + if old_version == 2: + _LOGGER.debug(f"GE Home: Migrating entry {config_entry.entry_id} from v{old_version} to v3") + + # Normalize username + username: str = data[CONF_USERNAME].strip().lower() + data[CONF_USERNAME] = username + + # Normalize region + region: str = data[CONF_REGION].strip().upper() + data[CONF_REGION] = region + + # Determine unique_id + unique_id: str = (config_entry.unique_id or username).strip().lower() + + # Update entry: data, version, and unique_id in one call + hass.config_entries.async_update_entry( + config_entry, + data=data, + version=3, + unique_id=unique_id, + ) + + _LOGGER.info ( + f"GE Home: Migration of entry {config_entry.entry_id} to v3 successful " + f"(unique_id='{unique_id}')" + ) + + return True + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up ge_home from a config entry.""" + + coordinators: dict[str, GeHomeUpdateCoordinator] = hass.data.setdefault(DOMAIN, {}) + + #try to get existing coordinator + existing: GeHomeUpdateCoordinator | None = coordinators.get(entry.entry_id) + + # try to unload the existing coordinator + if existing: + try: + _LOGGER.debug("Found existing coordinator, resetting before setup.") + await existing.async_reset() + except Exception: + _LOGGER.warning("Could not reset existing coordinator.", exc_info=True) + finally: + coordinators.pop(entry.entry_id, None) + + coordinator = GeHomeUpdateCoordinator(hass, entry) + coordinators[entry.entry_id] = 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") + except Exception as exc: + _LOGGER.exception("Unexpected error during coordinator setup", exc_info=True) + raise ConfigEntryNotReady from exc + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, coordinator.shutdown) + + _LOGGER.debug("Coordinator setup complete") + 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: HomeAssistant, entry: ConfigEntry): + """Update options.""" + await hass.config_entries.async_reload(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..fb30f5d --- /dev/null +++ b/custom_components/ge_home/binary_sensor.py @@ -0,0 +1,48 @@ +"""GE Home Sensor Entities""" +import logging +from collections.abc import Collection +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: Collection[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 to register') + 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..c6cf76b --- /dev/null +++ b/custom_components/ge_home/button.py @@ -0,0 +1,46 @@ +"""GE Home Button Entities""" +import logging +from collections.abc import Collection +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: Collection[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 to register') + 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..2869110 --- /dev/null +++ b/custom_components/ge_home/climate.py @@ -0,0 +1,47 @@ +"""GE Home Climate Entities""" +import logging +from collections.abc import Collection +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 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: Collection[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 to register') + 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..d393085 --- /dev/null +++ b/custom_components/ge_home/config_flow.py @@ -0,0 +1,160 @@ +"""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, VALIDATE_DATA_TIMEOUT, CONFIG_FLOW_VERSION +from .exceptions import HaAuthError, HaCannotConnect + +_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()) + } +) + +def _normalize_username(username: Optional[str]) -> str: + """Trim whitespace and lowercase the username.""" + if username is None or username.strip() == "": + raise HaAuthError("Username is required") + return username.strip().lower() + +def _normalize_password(password: Optional[str]) -> str: + """Trim whitespace from password.""" + if password is None or password.strip() == "": + raise HaAuthError("Password is required") + return password.strip() + +def _normalize_region(region: Optional[str]) -> str: + """Ensure valid region.""" + if region is None or not region.upper() in LOGIN_REGIONS.keys(): + raise HaAuthError("Invalid region") + return region.upper() + +def _get_user_schema(user_input: Optional[Dict] = None) -> vol.Schema: + """Return the user step schema, prefilled with previous input if available.""" + user_input = user_input or {} + return vol.Schema( + { + vol.Required(CONF_USERNAME, default=user_input.get(CONF_USERNAME, "")): str, + vol.Required(CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "")): str, + vol.Required(CONF_REGION, default=user_input.get(CONF_REGION, "")): vol.In(LOGIN_REGIONS.keys()) + } + ) + +async def validate_input(hass: core.HomeAssistant, data: dict): + """Validate the user input allows us to connect.""" + session = async_get_clientsession(hass) + username = _normalize_username(data.get(CONF_USERNAME)) + password = _normalize_password(data.get(CONF_PASSWORD)) + region = _normalize_region(data.get(CONF_REGION)) + + try: + async with async_timeout.timeout(VALIDATE_DATA_TIMEOUT): + await async_get_oauth2_token(session, username, password, region) + except (asyncio.TimeoutError, aiohttp.ClientError) as err: + _LOGGER.warning(f"Connection failure for user {username} in region {region}: {err}") + raise HaCannotConnect("Connection failure") + except (GeAuthFailedError, GeNotAuthenticatedError): + _LOGGER.warning(f"Authentication failure for user {username} in region {region}") + raise HaAuthError("Authentication failure") + except GeGeneralServerError: + _LOGGER.warning(f"Server error for user {username} in region {region}") + raise HaCannotConnect("Cannot connect (server error)") + except Exception as err: + _LOGGER.exception(f"Unknown connection failure for user {username} in region {region}") + raise HaCannotConnect("Unknown connection failure") from err + + return {"title": username.lower()} + +class GeHomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for GE Home.""" + + VERSION = CONFIG_FLOW_VERSION + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH + + async def _async_validate_input(self, user_input: dict): + """Map validation to HA-friendly error codes.""" + try: + info = await validate_input(self.hass, user_input) + return info, {} + except HaCannotConnect: + return None, {"base": "cannot_connect"} + except HaAuthError: + return None, {"base": "invalid_auth"} + except Exception: + return None, {"base": "unknown"} + + async def async_step_user(self, user_input: Optional[Dict] = None): + """Handle the initial step.""" + errors = {} + + if user_input: + username = _normalize_username(user_input.get(CONF_USERNAME)) + + # test uniqueness and abort if not unique + await self.async_set_unique_id(username) + self._abort_if_unique_id_configured() + + try: + info, errors = await self._async_validate_input(user_input) + if info: + return self.async_create_entry(title=info["title"], data=user_input) + + except Exception as err: + _LOGGER.exception(f"Unexpected error in user step: {err}") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", + data_schema=_get_user_schema(user_input), + errors=errors + ) + + async def async_step_reauth(self, user_input: Optional[dict] = None): + """Handle re-auth if login is invalid.""" + errors = {} + + if user_input: + username = _normalize_username(user_input.get(CONF_USERNAME)) + _, errors = await self._async_validate_input(user_input) + + if not errors: + for entry in self._async_current_entries(): + if entry.unique_id == username: + 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.get("base") != "invalid_auth": + return self.async_abort(reason=errors.get("base") or "unknown") + + return self.async_show_form( + step_id="reauth", + data_schema=_get_user_schema(user_input), + errors=errors + ) + + diff --git a/custom_components/ge_home/const.py b/custom_components/ge_home/const.py new file mode 100644 index 0000000..47dfda2 --- /dev/null +++ b/custom_components/ge_home/const.py @@ -0,0 +1,24 @@ +"""Constants for the gehome integration.""" + +DOMAIN = "ge_home" + +EVENT_ALL_APPLIANCES_READY = 'all_appliances_ready' +CONNECTION_NOTIFICATION_ID = "ge_home_connection" +CONFIG_FLOW_VERSION = 3 + +HA_REFRESH_INTERVAL = 60 +STATE_UPDATE_INTERVAL = 30 +CLIENT_START_TIMEOUT = 30 +INITIAL_UPDATE_TIMEOUT = 10 +VALIDATE_DATA_TIMEOUT = 10 + +MIN_RETRY_DELAY = 15 +MAX_RETRY_DELAY = 1800 +RECONNECT_JITTER = 0.2 +PERSISTENT_RETRY_LOG_INTERVAL = 300 +RETRY_OFFLINE_COUNT = 5 +NOTIFY_AFTER_RETRIES = 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..8dff3a3 --- /dev/null +++ b/custom_components/ge_home/devices/advantium.py @@ -0,0 +1,55 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +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 + + @property + def sw_version(self) -> str: + appVer = self.try_get_erd_value(ErdCode.APPLIANCE_SW_VERSION) + if appVer == "0.0.0.0": + appVer = self.try_get_erd_value(ErdCode.LCD_SW_VERSION) + wifiVer = self.try_get_erd_value(ErdCode.WIFI_MODULE_SW_VERSION) + + return 'Appliance=' + str(appVer or 'Unknown') + '/Wifi=' + str(wifiVer or 'Unknown') + + def get_all_entities(self) -> List[Entity]: + base_entities = super().get_all_entities() + + advantium_entities = [ + GeErdSensor(self, ErdCode.PERSONALITY, entity_category=EntityCategory.DIAGNOSTIC), + GeErdBinarySensor(self, ErdCode.UPPER_OVEN_REMOTE_ENABLED, self._single_name(ErdCode.UPPER_OVEN_REMOTE_ENABLED), entity_category=EntityCategory.DIAGNOSTIC), + GeErdBinarySensor(self, ErdCode.MICROWAVE_REMOTE_ENABLE, entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.UPPER_OVEN_DISPLAY_TEMPERATURE, self._single_name(ErdCode.UPPER_OVEN_DISPLAY_TEMPERATURE), entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.ADVANTIUM_KITCHEN_TIME_REMAINING, suggested_uom="h"), + GeErdSensor(self, ErdCode.ADVANTIUM_COOK_TIME_REMAINING, suggested_uom="h"), + GeAdvantium(self), + + #Cook Status + GeErdPropertySensor(self, ErdCode.ADVANTIUM_COOK_STATUS, "cook_mode", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.ADVANTIUM_COOK_STATUS, "termination_reason", icon_override="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.ADVANTIUM_COOK_STATUS, "preheat_status", icon_override="mdi:fire", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.ADVANTIUM_COOK_STATUS, "temperature", icon_override="mdi:thermometer", data_type_override=ErdDataType.INT, entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.ADVANTIUM_COOK_STATUS, "power_level", icon_override="mdi:gauge", data_type_override=ErdDataType.INT, entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.ADVANTIUM_COOK_STATUS, "warm_status", icon_override="mdi:radiator", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertyBinarySensor(self, ErdCode.ADVANTIUM_COOK_STATUS, "door_status", device_class_override="door", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertyBinarySensor(self, ErdCode.ADVANTIUM_COOK_STATUS, "sensing_active", icon_on_override="mdi:flash-auto", icon_off_override="mdi:flash-off", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertyBinarySensor(self, ErdCode.ADVANTIUM_COOK_STATUS, "cooling_fan_status", icon_on_override="mdi:fan", icon_off_override="mdi:fan-off", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertyBinarySensor(self, ErdCode.ADVANTIUM_COOK_STATUS, "oven_light_status", icon_on_override="mdi:lightbulb-on", icon_off_override="mdi:lightbulb-off", entity_category=EntityCategory.DIAGNOSTIC), + ] + 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..00703b9 --- /dev/null +++ b/custom_components/ge_home/devices/base.py @@ -0,0 +1,208 @@ +import asyncio +import logging +from propcache.api import cached_property +from typing import Dict, List, Optional + +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.device_registry import DeviceInfo +from gehomesdk import ( + GeAppliance, + ErdCode, + ErdCodeType, + ErdApplianceType, + ERD_BRAND_NAME_MAP, + ErdBrand +) + +from .const import BRAND_FIRST_LETTER_MAP, BRAND_SPECIAL_PREFIXES +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: 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 # type: ignore + + @cached_property + def serial_number(self) -> str: + return self.appliance.get_erd_value(ErdCode.SERIAL_NUMBER) + + @cached_property + def mac_addr(self) -> str: + return self.appliance.mac_addr + + @cached_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 + + @cached_property + def brand(self) -> str: + b: ErdBrand | None = self.try_get_erd_value(ErdCode.BRAND) + + if b in (None, ErdBrand.UNKNOWN, ErdBrand.NOT_DEFINED): + inferred = self._infer_brand_from_model(self.model_number) + b = inferred or ErdBrand.GE + + return ERD_BRAND_NAME_MAP.get(b, 'GE') + + @cached_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') + + @cached_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"{self.brand} {appliance_type} {self.serial_or_mac}" + + @property + def device_info(self) -> DeviceInfo: + """Device info dictionary.""" + + return { + "identifiers": {(DOMAIN, self.mac_addr)}, + "serial_number": self.serial_number, + "name": self.name, + "manufacturer": self.brand, + "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, entity_category=EntityCategory.DIAGNOSTIC), + 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 is not None and 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 + + def _infer_brand_from_model(self, model: str) -> Optional[ErdBrand]: + """ + Infer the appliance brand from model number using first-letter mapping + and special prefix handling. + """ + if not model: + _LOGGER.debug("Model number is empty, cannot infer brand.") + return None + + m = model.strip().upper() + + # Try special prefixes + for prefix, idx in BRAND_SPECIAL_PREFIXES.items(): + if m.startswith(prefix): + if len(m) > idx: + brand_letter = m[idx] + brand = BRAND_FIRST_LETTER_MAP.get(brand_letter) + if brand: + _LOGGER.debug(f"Model '{m}': inferred brand '{brand.name}' from prefix '{prefix}' at position {idx + 1}") + return brand + _LOGGER.debug(f"Model '{m}': prefix '{prefix}' found but brand letter at position {idx + 1} not recognized") + return None + + # Try general + first_letter = m[0] + brand = BRAND_FIRST_LETTER_MAP.get(first_letter) + if brand: + _LOGGER.debug(f"Model '{m}': inferred brand '{brand.name}' from first letter '{first_letter}'") + return brand + + # Log and return + _LOGGER.debug(f"Model '{m}': could not infer brand (first letter '{first_letter}' not in mapping)") + return None \ No newline at end of file diff --git a/custom_components/ge_home/devices/biac.py b/custom_components/ge_home/devices/biac.py new file mode 100644 index 0000000..05716b4 --- /dev/null +++ b/custom_components/ge_home/devices/biac.py @@ -0,0 +1,36 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +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, entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.AC_AMBIENT_TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.AC_FAN_SETTING, icon_override="mdi:fan", entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.AC_OPERATION_MODE, entity_category=EntityCategory.DIAGNOSTIC), + 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", entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.WAC_DEMAND_RESPONSE_STATE, entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.WAC_DEMAND_RESPONSE_POWER, uom_override="kW", entity_category=EntityCategory.DIAGNOSTIC), + ] + + 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..2dc5db3 --- /dev/null +++ b/custom_components/ge_home/devices/coffee_maker.py @@ -0,0 +1,67 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +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, entity_category=EntityCategory.DIAGNOSTIC), + GeErdBinarySensor(self, ErdCode.CCM_IS_DESCALING, entity_category=EntityCategory.DIAGNOSTIC), + GeCcmBrewSettingsButton(self), + GeErdButton(self, ErdCode.CCM_CANCEL_DESCALING, entity_category=EntityCategory.CONFIG), + GeErdButton(self, ErdCode.CCM_START_DESCALING, entity_category=EntityCategory.CONFIG), + 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, entity_category=EntityCategory.DIAGNOSTIC), + GeErdBinarySensor(self, ErdCode.CCM_OUT_OF_WATER, device_class_override="problem", entity_category=EntityCategory.DIAGNOSTIC), + GeCcmPotNotPresentBinarySensor(self, ErdCode.CCM_POT_PRESENT, device_class_override="problem", entity_category=EntityCategory.DIAGNOSTIC) + ] + + 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/const.py b/custom_components/ge_home/devices/const.py new file mode 100644 index 0000000..2be4c3a --- /dev/null +++ b/custom_components/ge_home/devices/const.py @@ -0,0 +1,22 @@ +from gehomesdk import ErdBrand + +BRAND_FIRST_LETTER_MAP: dict[str, ErdBrand] = { + "C": ErdBrand.GE_CAFE, + "G": ErdBrand.GE, + "J": ErdBrand.GE, + "N": ErdBrand.GE, + "A": ErdBrand.GE, + "F": ErdBrand.GE, + "H": ErdBrand.HOTPOINT, + "P": ErdBrand.GE_PROFILE, + "Q": ErdBrand.HEIER, + "Z": ErdBrand.GE_MONOGRAM, + "R": ErdBrand.HOTPOINT, + "M": ErdBrand.HEIER, + "U": ErdBrand.UNKNOWN, # also might be universal +} + +BRAND_SPECIAL_PREFIXES: dict[str, int] = { + "OPAL01": 6, # Opal ice maker: brand letter at 7th position + "XP": 1, # XP Opal variant: brand letter at 2nd position +} diff --git a/custom_components/ge_home/devices/cooktop.py b/custom_components/ge_home/devices/cooktop.py new file mode 100644 index 0000000..7a81cf0 --- /dev/null +++ b/custom_components/ge_home/devices/cooktop.py @@ -0,0 +1,91 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.helpers.entity import Entity +from gehomesdk import ( + ErdCode, + ErdDataType, + ErdApplianceType, + ErdCooktopConfig, + CooktopStatus, +) + +from .base import ApplianceApi +from ..entities import ( + GeCooktopStatusBinarySensor, + 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 cooktop status, preferring extended data when present + cooktop_status_erd = ErdCode.COOKTOP_STATUS_EXT + cooktop_status: CooktopStatus | None = self.try_get_erd_value( + ErdCode.COOKTOP_STATUS_EXT + ) + + # if we didn't get it, fall back to the legacy status code + if cooktop_status is None: + cooktop_status_erd = ErdCode.COOKTOP_STATUS + cooktop_status = self.try_get_erd_value(ErdCode.COOKTOP_STATUS) + + # if we got a status through either mechanism, we can add the entities + if cooktop_status is not None: + cooktop_entities.append( + GeCooktopStatusBinarySensor(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", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ) + 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..0cc7f5d --- /dev/null +++ b/custom_components/ge_home/devices/dehumidifier.py @@ -0,0 +1,44 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +from homeassistant.helpers.entity import Entity +from gehomesdk import ( + ErdCode, + ErdApplianceType +) + +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, entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.DHUM_TARGET_HUMIDITY, entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DHUM_MAINTENANCE, "empty_bucket", device_class_override="problem", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DHUM_MAINTENANCE, "clean_filter", device_class_override="problem", entity_category=EntityCategory.DIAGNOSTIC), + 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..db436c8 --- /dev/null +++ b/custom_components/ge_home/devices/dishwasher.py @@ -0,0 +1,65 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +from homeassistant.helpers.entity import Entity +from gehomesdk import ErdCode, ErdApplianceType, ErdRemoteCommand + +from .base import ApplianceApi +from ..entities import GeErdSensor, GeErdBinarySensor, GeErdPropertySensor, GeErdNumber, GeDishwasherCommandButton + +_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 = [ + GeErdSensor(self, ErdCode.DISHWASHER_CYCLE_NAME), + GeErdSensor(self, ErdCode.DISHWASHER_CYCLE_STATE, icon_override="mdi:state-machine"), + GeErdSensor(self, ErdCode.DISHWASHER_OPERATING_MODE), + GeErdNumber(self, ErdCode.DISHWASHER_PODS_REMAINING_VALUE, uom_override="pods", min_value=0, max_value=255, entity_category=EntityCategory.CONFIG), + GeErdPropertySensor(self, ErdCode.DISHWASHER_REMINDERS, "add_rinse_aid", icon_override="mdi:shimmer", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_REMINDERS, "clean_filter", icon_override="mdi:dishwasher-alert", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_REMINDERS, "sanitized", icon_override="mdi:silverware-clean", entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.DISHWASHER_TIME_REMAINING, suggested_uom="h"), + GeErdBinarySensor(self, ErdCode.DISHWASHER_DOOR_STATUS, entity_category=EntityCategory.DIAGNOSTIC), + GeErdBinarySensor(self, ErdCode.DISHWASHER_IS_CLEAN), + + #User Setttings + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "mute", icon_override="mdi:volume-mute", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "lock_control", icon_override="mdi:lock", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "sabbath", icon_override="mdi:star-david", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "cycle_mode", icon_override="mdi:state-machine", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "presoak", icon_override="mdi:water", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "bottle_jet", icon_override="mdi:bottle-tonic-outline", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "wash_temp", icon_override="mdi:coolant-temperature", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "rinse_aid", icon_override="mdi:shimmer", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "dry_option", icon_override="mdi:fan", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "wash_zone", icon_override="mdi:dock-top", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "delay_hours", icon_override="mdi:clock-fast", entity_category=EntityCategory.DIAGNOSTIC), + + #Cycle Counts + GeErdPropertySensor(self, ErdCode.DISHWASHER_CYCLE_COUNTS, "started", icon_override="mdi:counter", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_CYCLE_COUNTS, "completed", icon_override="mdi:counter", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_CYCLE_COUNTS, "reset", icon_override="mdi:counter", entity_category=EntityCategory.DIAGNOSTIC) + ] + + # check for remote command availability and add if present + if self.has_erd_code(ErdCode.DISHWASHER_REMOTE_START_ENABLE): + dishwasher_entities.extend( + [ + GeErdBinarySensor(self, ErdCode.DISHWASHER_REMOTE_START_ENABLE, entity_category=EntityCategory.DIAGNOSTIC), + GeDishwasherCommandButton(self, ErdCode.DISHWASHER_REMOTE_START_COMMAND, ErdRemoteCommand.START_RESUME), + GeDishwasherCommandButton(self, ErdCode.DISHWASHER_REMOTE_START_COMMAND, ErdRemoteCommand.PAUSE), + GeDishwasherCommandButton(self, ErdCode.DISHWASHER_REMOTE_START_COMMAND, ErdRemoteCommand.CANCEL) + ] + ) + + 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..8fed732 --- /dev/null +++ b/custom_components/ge_home/devices/dryer.py @@ -0,0 +1,68 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +from homeassistant.helpers.entity import Entity +from gehomesdk import ErdCode, ErdApplianceType + +from .base import ApplianceApi +from ..entities import GeErdSensor, GeErdBinarySensor, GeErdButton +from ..entities.laundry.ge_dryer_cycle_button import GeDryerCycleButton + + +_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", entity_category=EntityCategory.DIAGNOSTIC), + 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, suggested_uom="min"), + GeErdSensor(self, ErdCode.LAUNDRY_DELAY_TIME_REMAINING, suggested_uom="h"), + GeErdBinarySensor(self, ErdCode.LAUNDRY_DOOR, entity_category=EntityCategory.DIAGNOSTIC), + GeErdBinarySensor(self, ErdCode.LAUNDRY_REMOTE_STATUS, icon_on_override="mdi:tumble-dryer", icon_off_override="mdi:tumble-dryer", entity_category=EntityCategory.DIAGNOSTIC), + GeErdBinarySensor(self, ErdCode.LAUNDRY_DRYER_BLOCKED_VENT_FAULT, icon_on_override="mdi:alert-circle", icon_off_override="mdi:alert-circle", entity_category=EntityCategory.DIAGNOSTIC), + ] + + dryer_entities = self.get_dryer_entities() + + # Add the start cycle button + dryer_entities.append(GeDryerCycleButton(self)) + + entities = base_entities + common_entities + dryer_entities + return entities + + def get_dryer_entities(self): + dryer_entities = [] + + if self.has_erd_code(ErdCode.LAUNDRY_DRYER_DRYNESS_LEVEL): + dryer_entities.extend([GeErdSensor(self, ErdCode.LAUNDRY_DRYER_DRYNESS_LEVEL, entity_category=EntityCategory.DIAGNOSTIC)]) + if self.has_erd_code(ErdCode.LAUNDRY_DRYER_DRYNESSNEW_LEVEL): + dryer_entities.extend([GeErdSensor(self, ErdCode.LAUNDRY_DRYER_DRYNESSNEW_LEVEL, entity_category=EntityCategory.DIAGNOSTIC)]) + if self.has_erd_code(ErdCode.LAUNDRY_DRYER_TEMPERATURE_OPTION): + dryer_entities.extend([GeErdSensor(self, ErdCode.LAUNDRY_DRYER_TEMPERATURE_OPTION, entity_category=EntityCategory.DIAGNOSTIC)]) + if self.has_erd_code(ErdCode.LAUNDRY_DRYER_TEMPERATURENEW_OPTION): + dryer_entities.extend([GeErdSensor(self, ErdCode.LAUNDRY_DRYER_TEMPERATURENEW_OPTION, entity_category=EntityCategory.DIAGNOSTIC)]) + if self.has_erd_code(ErdCode.LAUNDRY_DRYER_TUMBLE_STATUS): + dryer_entities.extend([GeErdSensor(self, ErdCode.LAUNDRY_DRYER_TUMBLE_STATUS, entity_category=EntityCategory.DIAGNOSTIC)]) + if self.has_erd_code(ErdCode.LAUNDRY_DRYER_EXTENDED_TUMBLE_OPTION_SELECTION): + dryer_entities.extend([GeErdSensor(self, ErdCode.LAUNDRY_DRYER_EXTENDED_TUMBLE_OPTION_SELECTION, entity_category=EntityCategory.DIAGNOSTIC)]) + if self.has_erd_code(ErdCode.LAUNDRY_DRYER_WASHERLINK_STATUS): + dryer_entities.extend([GeErdBinarySensor(self, ErdCode.LAUNDRY_DRYER_WASHERLINK_STATUS, entity_category=EntityCategory.DIAGNOSTIC)]) + if self.has_erd_code(ErdCode.LAUNDRY_DRYER_LEVEL_SENSOR_DISABLED): + dryer_entities.extend([GeErdBinarySensor(self, ErdCode.LAUNDRY_DRYER_LEVEL_SENSOR_DISABLED, entity_category=EntityCategory.DIAGNOSTIC)]) + if self.has_erd_code(ErdCode.LAUNDRY_DRYER_SHEET_USAGE_CONFIGURATION): + dryer_entities.extend([GeErdSensor(self, ErdCode.LAUNDRY_DRYER_SHEET_USAGE_CONFIGURATION, entity_category=EntityCategory.DIAGNOSTIC)]) + 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", entity_category=EntityCategory.DIAGNOSTIC)]) + if self.has_erd_code(ErdCode.LAUNDRY_DRYER_ECODRY_OPTION_SELECTION): + dryer_entities.extend([GeErdSensor(self, ErdCode.LAUNDRY_DRYER_ECODRY_OPTION_SELECTION, entity_category=EntityCategory.DIAGNOSTIC)]) + + return dryer_entities \ No newline at end of file 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..11844fd --- /dev/null +++ b/custom_components/ge_home/devices/dual_dishwasher.py @@ -0,0 +1,93 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +from homeassistant.helpers.entity import Entity +from gehomesdk import ErdCode, ErdApplianceType, ErdRemoteCommand + +from .base import ApplianceApi +from ..entities import GeErdSensor, GeErdBinarySensor, GeErdPropertySensor, GeErdPropertyBinarySensor, GeDishwasherCommandButton + +_LOGGER = logging.getLogger(__name__) + + +class DualDishwasherApi(ApplianceApi): + """API class for dual dishwasher objects""" + APPLIANCE_TYPE = ErdApplianceType.DUAL_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", suggested_uom="h"), + GeErdBinarySensor(self, ErdCode.DISHWASHER_DOOR_STATUS, erd_override="lower_door_status", entity_category=EntityCategory.DIAGNOSTIC), + + #Reminders + GeErdPropertySensor(self, ErdCode.DISHWASHER_REMINDERS, "add_rinse_aid", erd_override="lower_reminder", icon_override="mdi:shimmer", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_REMINDERS, "clean_filter", erd_override="lower_reminder", icon_override="mdi:dishwasher-alert", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_REMINDERS, "sanitized", erd_override="lower_reminder", icon_override="mdi:silverware-clean", entity_category=EntityCategory.DIAGNOSTIC), + + #User Setttings + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "mute", erd_override="lower_setting", icon_override="mdi:volume-mute", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "lock_control", erd_override="lower_setting", icon_override="mdi:lock", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "sabbath", erd_override="lower_setting", icon_override="mdi:star-david", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "cycle_mode", erd_override="lower_setting", icon_override="mdi:state-machine", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "presoak", erd_override="lower_setting", icon_override="mdi:water", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "bottle_jet", erd_override="lower_setting", icon_override="mdi:bottle-tonic-outline", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "wash_temp", erd_override="lower_setting", icon_override="mdi:coolant-temperature", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "rinse_aid", erd_override="lower_setting", icon_override="mdi:shimmer", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "dry_option", erd_override="lower_setting", icon_override="mdi:fan", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "wash_zone", erd_override="lower_setting", icon_override="mdi:dock-top", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_USER_SETTING, "delay_hours", erd_override="lower_setting", icon_override="mdi:clock-fast", entity_category=EntityCategory.DIAGNOSTIC) + ] + + upper_entities = [ + 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", suggested_uom="h"), + GeErdBinarySensor(self, ErdCode.DISHWASHER_UPPER_DOOR_STATUS, erd_override="upper_door_status", entity_category=EntityCategory.DIAGNOSTIC), + + #Reminders + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_REMINDERS, "add_rinse_aid", erd_override="upper_reminder", icon_override="mdi:shimmer", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_REMINDERS, "clean_filter", erd_override="upper_reminder", icon_override="mdi:dishwasher-alert", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_REMINDERS, "sanitized", erd_override="upper_reminder", icon_override="mdi:silverware-clean", entity_category=EntityCategory.DIAGNOSTIC), + + #User Setttings + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_USER_SETTING, "mute", erd_override="upper_setting", icon_override="mdi:volume-mute", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_USER_SETTING, "lock_control", erd_override="upper_setting", icon_override="mdi:lock", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_USER_SETTING, "sabbath", erd_override="upper_setting", icon_override="mdi:star-david", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_USER_SETTING, "cycle_mode", erd_override="upper_setting", icon_override="mdi:state-machine", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_USER_SETTING, "presoak", erd_override="upper_setting", icon_override="mdi:water", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_USER_SETTING, "bottle_jet", erd_override="upper_setting", icon_override="mdi:bottle-tonic-outline", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_USER_SETTING, "wash_temp", erd_override="upper_setting", icon_override="mdi:coolant-temperature", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_USER_SETTING, "rinse_aid", erd_override="upper_setting", icon_override="mdi:shimmer", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_USER_SETTING, "dry_option", erd_override="upper_setting", icon_override="mdi:fan", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_USER_SETTING, "wash_zone", erd_override="upper_setting", icon_override="mdi:dock-top", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.DISHWASHER_UPPER_USER_SETTING, "delay_hours", erd_override="upper_setting", icon_override="mdi:clock-fast", entity_category=EntityCategory.DIAGNOSTIC) + ] + + # Remote commands are always supported, enabled by a physical button per tub, disabled when the tub is opened (lower) + if True: + lower_entities.extend( + [ + GeErdPropertyBinarySensor(self, ErdCode.DISHWASHER_USER_SETTING, "wifi_enabled", erd_override="lower_remote_command_enable", icon_off_override="mdi:wifi-off", icon_on_override="mdi:wifi"), + GeDishwasherCommandButton(self, ErdCode.DISHWASHER_REMOTE_START_COMMAND, ErdRemoteCommand.START_RESUME, erd_override="lower_remote_command"), + GeDishwasherCommandButton(self, ErdCode.DISHWASHER_REMOTE_START_COMMAND, ErdRemoteCommand.PAUSE, erd_override="lower_remote_command"), + GeDishwasherCommandButton(self, ErdCode.DISHWASHER_REMOTE_START_COMMAND, ErdRemoteCommand.CANCEL, erd_override="lower_remote_command") + ] + ) + + # Remote commands are always supported, enabled by a physical button per tub, disabled when the tub is opened (upper) + if True: + upper_entities.extend( + [ + GeErdPropertyBinarySensor(self, ErdCode.DISHWASHER_UPPER_USER_SETTING, "wifi_enabled", erd_override="upper_remote_command_enable", icon_off_override="mdi:wifi-off", icon_on_override="mdi:wifi"), + GeDishwasherCommandButton(self, ErdCode.DISHWASHER_UPPER_REMOTE_START_COMMAND, ErdRemoteCommand.START_RESUME, erd_override="upper_remote_command"), + GeDishwasherCommandButton(self, ErdCode.DISHWASHER_UPPER_REMOTE_START_COMMAND, ErdRemoteCommand.PAUSE, erd_override="upper_remote_command"), + GeDishwasherCommandButton(self, ErdCode.DISHWASHER_UPPER_REMOTE_START_COMMAND, ErdRemoteCommand.CANCEL, erd_override="upper_remote_command") + ] + ) + + 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..8cb2456 --- /dev/null +++ b/custom_components/ge_home/devices/espresso_maker.py @@ -0,0 +1,35 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +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, entity_category=EntityCategory.DIAGNOSTIC), + GeErdButton(self, ErdCode.CCM_CANCEL_DESCALING, entity_category=EntityCategory.CONFIG), + GeErdButton(self, ErdCode.CCM_START_DESCALING, entity_category=EntityCategory.DIAGNOSTIC), + GeErdBinarySensor(self, ErdCode.CCM_OUT_OF_WATER, device_class_override="problem", entity_category=EntityCategory.DIAGNOSTIC), + ] + + 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..100728e --- /dev/null +++ b/custom_components/ge_home/devices/fridge.py @@ -0,0 +1,144 @@ +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import SensorDeviceClass +import logging +from typing import List + +from homeassistant.const import EntityCategory +from homeassistant.helpers.entity import Entity +from gehomesdk import ( + ErdCode, + ErdApplianceType, + ErdOnOff, + ErdHotWaterStatus, + FridgeIceBucketStatus, + IceMakerControlStatus, + ErdFilterStatus, + HotWaterStatus, + FridgeModelInfo, + ErdConvertableDrawerMode, + ErdDataType +) + +from .base import ApplianceApi +# This block is now split to import from the correct sub-folders +from ..entities import ( + ErdOnOffBoolConverter, + GeErdSensor, + GeErdBinarySensor, + GeErdSwitch, + GeErdSelect, + GeErdLight, + GeErdPropertySensor, + GeErdPropertyBinarySensor +) +from ..entities.fridge import ( + GeFridge, + GeFreezer, + GeDispenser, + ConvertableDrawerModeOptionsConverter, + GeFridgeIceControlSwitch, + GeKCupSwitch +) + + +_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: List[Entity] = [] + freezer_entities: List[Entity] = [] + dispenser_entities: List[Entity] = [] + + # Get the statuses used to determine presence + + ice_maker_control: IceMakerControlStatus | None = self.try_get_erd_value(ErdCode.ICE_MAKER_CONTROL) + ice_bucket_status: FridgeIceBucketStatus | None = self.try_get_erd_value(ErdCode.ICE_MAKER_BUCKET_STATUS) + water_filter: ErdFilterStatus | None = self.try_get_erd_value(ErdCode.WATER_FILTER_STATUS) + air_filter: ErdFilterStatus | None = self.try_get_erd_value(ErdCode.AIR_FILTER_STATUS) + hot_water_status: HotWaterStatus | None = self.try_get_erd_value(ErdCode.HOT_WATER_STATUS) + fridge_model_info: FridgeModelInfo | None = self.try_get_erd_value(ErdCode.FRIDGE_MODEL_INFO) + convertable_drawer: ErdConvertableDrawerMode | None = self.try_get_erd_value(ErdCode.CONVERTABLE_DRAWER_MODE) + + interior_light: int | None = self.try_get_erd_value(ErdCode.INTERIOR_LIGHT) + proximity_light: ErdOnOff | None = self.try_get_erd_value(ErdCode.PROXIMITY_LIGHT) + display_mode: ErdOnOff | None = self.try_get_erd_value(ErdCode.DISPLAY_MODE) + lockout_mode: ErdOnOff | None = self.try_get_erd_value(ErdCode.LOCKOUT_MODE) + turbo_cool: ErdOnOff | None = self.try_get_erd_value(ErdCode.TURBO_COOL_STATUS) + turbo_freeze: ErdOnOff | None = self.try_get_erd_value(ErdCode.TURBO_FREEZE_STATUS) + ice_boost: ErdOnOff | None = self.try_get_erd_value(ErdCode.FRIDGE_ICE_BOOST) + + units = self.hass.config.units + + # Common entities + common_entities = [ + GeErdSensor(self, ErdCode.FRIDGE_MODEL_INFO, entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.DOOR_STATUS, entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertyBinarySensor(self, ErdCode.DOOR_STATUS, "any_open", entity_category=EntityCategory.DIAGNOSTIC) + ] + 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, entity_category=EntityCategory.DIAGNOSTIC)) + + # 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, entity_category=EntityCategory.CONFIG)) + if(ice_maker_control and ice_maker_control.status_fridge != ErdOnOff.NA): + fridge_entities.append(GeErdPropertyBinarySensor(self, ErdCode.ICE_MAKER_CONTROL, "status_fridge", entity_category=EntityCategory.DIAGNOSTIC)) + fridge_entities.append(GeFridgeIceControlSwitch(self, "fridge")) + if(water_filter and water_filter != ErdFilterStatus.NA): + fridge_entities.append(GeErdSensor(self, ErdCode.WATER_FILTER_STATUS, entity_category=EntityCategory.DIAGNOSTIC)) + if(air_filter and air_filter != ErdFilterStatus.NA): + fridge_entities.append(GeErdSensor(self, ErdCode.AIR_FILTER_STATUS, entity_category=EntityCategory.DIAGNOSTIC)) + if(ice_bucket_status and ice_bucket_status.is_present_fridge): + fridge_entities.append(GeErdPropertySensor(self, ErdCode.ICE_MAKER_BUCKET_STATUS, "state_full_fridge", entity_category=EntityCategory.DIAGNOSTIC)) + if(interior_light and interior_light != 255): + fridge_entities.append(GeErdLight(self, ErdCode.INTERIOR_LIGHT, entity_category=EntityCategory.CONFIG)) + 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", entity_category=EntityCategory.CONFIG)) + if(convertable_drawer and convertable_drawer != ErdConvertableDrawerMode.NA): + fridge_entities.append(GeErdSelect(self, ErdCode.CONVERTABLE_DRAWER_MODE, ConvertableDrawerModeOptionsConverter(units), entity_category=EntityCategory.CONFIG)) + 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", entity_category=EntityCategory.CONFIG)) + 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", entity_category=EntityCategory.CONFIG)) + + # 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, entity_category=EntityCategory.CONFIG)) + if ice_boost is not None: + freezer_entities.append(GeErdSwitch(self, ErdCode.FRIDGE_ICE_BOOST, entity_category=EntityCategory.CONFIG)) + if(ice_maker_control and ice_maker_control.status_freezer != ErdOnOff.NA): + freezer_entities.append(GeErdPropertyBinarySensor(self, ErdCode.ICE_MAKER_CONTROL, "status_freezer", entity_category=EntityCategory.DIAGNOSTIC)) + 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", entity_category=EntityCategory.DIAGNOSTIC)) + + # Dispenser entities + if(hot_water_status and hot_water_status.status != ErdHotWaterStatus.NA): + dispenser_entities.extend([ + GeErdBinarySensor(self, ErdCode.HOT_WATER_IN_USE, entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.HOT_WATER_SET_TEMP, entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.HOT_WATER_STATUS, "status", icon_override="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.HOT_WATER_STATUS, "time_until_ready", icon_override="mdi:timer-outline", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.HOT_WATER_STATUS, "current_temp", device_class_override=SensorDeviceClass.TEMPERATURE, data_type_override=ErdDataType.INT, entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertyBinarySensor(self, ErdCode.HOT_WATER_STATUS, "faulted", device_class_override=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC), + GeDispenser(self), + GeKCupSwitch(self) + ]) + + entities = base_entities + common_entities + fridge_entities + freezer_entities + dispenser_entities + return entities \ No newline at end of file diff --git a/custom_components/ge_home/devices/hood.py b/custom_components/ge_home/devices/hood.py new file mode 100644 index 0000000..2c03b1e --- /dev/null +++ b/custom_components/ge_home/devices/hood.py @@ -0,0 +1,68 @@ +import logging +from typing import List + +from homeassistant.helpers.entity import Entity +from gehomesdk import ( + ErdCode, + ErdApplianceType, + ErdHoodFanSpeedAvailability, + ErdHoodLightLevelAvailability, + ErdHoodFanSpeed, + ErdHoodLightLevel, + 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() + + #old-style entities + fan_availability: ErdHoodFanSpeedAvailability | None = self.try_get_erd_value(ErdCode.HOOD_FAN_SPEED_AVAILABILITY) + light_availability: ErdHoodLightLevelAvailability | None = self.try_get_erd_value(ErdCode.HOOD_LIGHT_LEVEL_AVAILABILITY) + timer_availability: ErdOnOff | None = self.try_get_erd_value(ErdCode.HOOD_TIMER_AVAILABILITY) + + #new-style entities + available_fan_speeds: int | None = self.try_get_erd_value(ErdCode.HOOD_AVAILABLE_FAN_SPEEDS) + available_light_levels: int | None = self.try_get_erd_value(ErdCode.HOOD_AVAILABLE_LIGHT_LEVELS) + actual_fan_speed: ErdHoodFanSpeed | None = self.try_get_erd_value(ErdCode.HOOD_ACTUAL_FAN_SPEED) + actual_light_level: ErdHoodLightLevel | None = self.try_get_erd_value(ErdCode.HOOD_ACTUAL_LIGHT_LEVEL) + + hood_entities: List[Entity] = [ + #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"), + ] + + #old-style + if fan_availability is not None and fan_availability.is_available: + hood_entities.append(GeHoodFanSpeedSelect(self, ErdCode.HOOD_FAN_SPEED)) + if light_availability is not None and light_availability.is_available: + hood_entities.append(GeHoodLightLevelSelect(self, ErdCode.HOOD_LIGHT_LEVEL)) + + #new-style + if available_fan_speeds is not None and available_fan_speeds > 0 and actual_fan_speed is not None: + hood_entities.append(GeHoodFanSpeedSelect(self, ErdCode.HOOD_ACTUAL_FAN_SPEED, ErdCode.HOOD_REQUESTED_FAN_SPEED)) + if available_light_levels is not None and available_light_levels > 0 and actual_light_level is not None: + hood_entities.append(GeHoodLightLevelSelect(self, ErdCode.HOOD_ACTUAL_LIGHT_LEVEL, ErdCode.HOOD_REQUESTED_LIGHT_LEVEL)) + + #timer + if timer_availability is not None: + hood_entities.append(GeErdTimerSensor(self, ErdCode.HOOD_TIMER, suggested_uom="h")) + + 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..6f43079 --- /dev/null +++ b/custom_components/ge_home/devices/microwave.py @@ -0,0 +1,56 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +from homeassistant.helpers.entity import Entity +from gehomesdk import ( + ErdCode, + ErdApplianceType, + ErdHoodFanSpeedAvailability, + ErdHoodLightLevelAvailability +) + +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 | None = self.try_get_erd_value(ErdCode.HOOD_FAN_SPEED_AVAILABILITY) + light_availability: ErdHoodLightLevelAvailability | None = self.try_get_erd_value(ErdCode.HOOD_LIGHT_LEVEL_AVAILABILITY) + + mwave_entities = [ + GeErdBinarySensor(self, ErdCode.MICROWAVE_REMOTE_ENABLE, entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.MICROWAVE_STATE, "status", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertyBinarySensor(self, ErdCode.MICROWAVE_STATE, "door_status", device_class_override="door", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.MICROWAVE_STATE, "cook_mode", icon_override="mdi:food-turkey", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.MICROWAVE_STATE, "power_level", icon_override="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.MICROWAVE_STATE, "temperature", icon_override="mdi:thermometer", entity_category=EntityCategory.DIAGNOSTIC), + GeErdTimerSensor(self, ErdCode.MICROWAVE_COOK_TIMER, suggested_uom="min"), + GeErdTimerSensor(self, ErdCode.MICROWAVE_KITCHEN_TIMER, suggested_uom="h") + ] + + 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..3be1de6 --- /dev/null +++ b/custom_components/ge_home/devices/oim.py @@ -0,0 +1,41 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +from homeassistant.helpers.entity import Entity +from gehomesdk import ( + ErdCode, + ErdApplianceType +) + +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, entity_category=EntityCategory.DIAGNOSTIC), + GeErdBinarySensor(self, ErdCode.OIM_FILTER_STATUS, device_class_override="problem", entity_category=EntityCategory.DIAGNOSTIC), + GeErdBinarySensor(self, ErdCode.OIM_NEEDS_DESCALING, device_class_override="problem", entity_category=EntityCategory.DIAGNOSTIC), + GeErdSelect(self, ErdCode.OIM_LIGHT_LEVEL, OimLightLevelOptionsConverter(), entity_category=EntityCategory.CONFIG), + 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..bdd0c4c --- /dev/null +++ b/custom_components/ge_home/devices/oven.py @@ -0,0 +1,358 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.helpers.entity import Entity +from gehomesdk import ( + ErdCode, + ErdDataType, + ErdApplianceType, + OvenConfiguration, + ErdCooktopConfig, + CooktopStatus, + ErdOvenLightLevel, + ErdOvenLightLevelAvailability, + ErdOvenWarmingState, +) + +from .base import ApplianceApi +from ..entities import ( + GeErdSensor, + GeErdTimerSensor, + GeErdBinarySensor, + GeCooktopStatusBinarySensor, + 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 | None = self.try_get_erd_value( + ErdCode.UPPER_OVEN_LIGHT + ) + upper_light_availability: ErdOvenLightLevelAvailability | None = ( + self.try_get_erd_value(ErdCode.UPPER_OVEN_LIGHT_AVAILABILITY) + ) + lower_light: ErdOvenLightLevel | None = self.try_get_erd_value( + ErdCode.LOWER_OVEN_LIGHT + ) + lower_light_availability: ErdOvenLightLevelAvailability | None = ( + self.try_get_erd_value(ErdCode.LOWER_OVEN_LIGHT_AVAILABILITY) + ) + + upper_warm_drawer: ErdOvenWarmingState | None = self.try_get_erd_value( + ErdCode.UPPER_OVEN_WARMING_DRAWER_STATE + ) + lower_warm_drawer: ErdOvenWarmingState | None = self.try_get_erd_value( + ErdCode.LOWER_OVEN_WARMING_DRAWER_STATE + ) + warm_drawer: ErdOvenWarmingState | None = 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, + entity_category=EntityCategory.DIAGNOSTIC, + ), + GeErdSensor( + self, + ErdCode.LOWER_OVEN_CURRENT_STATE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + GeErdSensor( + self, ErdCode.LOWER_OVEN_COOK_TIME_REMAINING, suggested_uom="h" + ), + GeErdTimerSensor( + self, ErdCode.LOWER_OVEN_KITCHEN_TIMER, suggested_uom="h" + ), + GeErdSensor( + self, + ErdCode.LOWER_OVEN_USER_TEMP_OFFSET, + entity_category=EntityCategory.DIAGNOSTIC, + ), + GeErdSensor( + self, + ErdCode.LOWER_OVEN_DISPLAY_TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + GeErdBinarySensor( + self, + ErdCode.LOWER_OVEN_REMOTE_ENABLED, + entity_category=EntityCategory.DIAGNOSTIC, + ), + 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, + entity_category=EntityCategory.DIAGNOSTIC, + ) + ) + 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, + entity_category=EntityCategory.DIAGNOSTIC, + ) + ) + + oven_entities.extend( + [ + GeErdSensor( + self, + ErdCode.UPPER_OVEN_COOK_MODE, + self._single_name( + ErdCode.UPPER_OVEN_COOK_MODE, not oven_config.has_lower_oven + ), + entity_category=EntityCategory.DIAGNOSTIC, + ), + GeErdSensor( + self, + ErdCode.UPPER_OVEN_CURRENT_STATE, + self._single_name( + ErdCode.UPPER_OVEN_CURRENT_STATE, not oven_config.has_lower_oven + ), + entity_category=EntityCategory.DIAGNOSTIC, + ), + GeErdSensor( + self, + ErdCode.UPPER_OVEN_COOK_TIME_REMAINING, + self._single_name( + ErdCode.UPPER_OVEN_COOK_TIME_REMAINING, + not oven_config.has_lower_oven, + ), + suggested_uom="h", + ), + GeErdTimerSensor( + self, + ErdCode.UPPER_OVEN_KITCHEN_TIMER, + self._single_name( + ErdCode.UPPER_OVEN_KITCHEN_TIMER, not oven_config.has_lower_oven + ), + suggested_uom="h", + ), + GeErdSensor( + self, + ErdCode.UPPER_OVEN_USER_TEMP_OFFSET, + self._single_name( + ErdCode.UPPER_OVEN_USER_TEMP_OFFSET, + not oven_config.has_lower_oven, + ), + entity_category=EntityCategory.DIAGNOSTIC, + ), + GeErdSensor( + self, + ErdCode.UPPER_OVEN_DISPLAY_TEMPERATURE, + self._single_name( + ErdCode.UPPER_OVEN_DISPLAY_TEMPERATURE, + not oven_config.has_lower_oven, + ), + entity_category=EntityCategory.DIAGNOSTIC, + ), + GeErdBinarySensor( + self, + ErdCode.UPPER_OVEN_REMOTE_ENABLED, + self._single_name( + ErdCode.UPPER_OVEN_REMOTE_ENABLED, + not oven_config.has_lower_oven, + ), + entity_category=EntityCategory.DIAGNOSTIC, + ), + 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, + not oven_config.has_lower_oven, + ), + entity_category=EntityCategory.DIAGNOSTIC, + ) + ) + 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, not 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, + not 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, + not oven_config.has_lower_oven, + ), + entity_category=EntityCategory.DIAGNOSTIC, + ) + ) + + if oven_config.has_warming_drawer and warm_drawer is not None: + oven_entities.append( + GeErdSensor( + self, + ErdCode.WARMING_DRAWER_STATE, + entity_category=EntityCategory.DIAGNOSTIC, + ) + ) + + if cooktop_config == ErdCooktopConfig.PRESENT: + # attempt to get cooktop status, preferring extended data when present + cooktop_status_erd = ErdCode.COOKTOP_STATUS_EXT + cooktop_status: CooktopStatus | None = self.try_get_erd_value( + ErdCode.COOKTOP_STATUS_EXT + ) + + # if we didn't get it, fall back to the legacy status code + if cooktop_status is None: + cooktop_status_erd = ErdCode.COOKTOP_STATUS + cooktop_status = self.try_get_erd_value(ErdCode.COOKTOP_STATUS) + + # if we got a status through either mechanism, we can add the entities + if cooktop_status is not None: + cooktop_entities.append( + GeCooktopStatusBinarySensor(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", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ) + 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..066e0a3 --- /dev/null +++ b/custom_components/ge_home/devices/pac.py @@ -0,0 +1,32 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +from homeassistant.helpers.entity import Entity +from gehomesdk 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, entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.AC_AMBIENT_TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.AC_FAN_SETTING, icon_override="mdi:fan", entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.AC_OPERATION_MODE, entity_category=EntityCategory.DIAGNOSTIC), + 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..acfce4e --- /dev/null +++ b/custom_components/ge_home/devices/sac.py @@ -0,0 +1,38 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +from homeassistant.helpers.entity import Entity +from gehomesdk 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, entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.AC_AMBIENT_TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.AC_FAN_SETTING, icon_override="mdi:fan", entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.AC_OPERATION_MODE, entity_category=EntityCategory.DIAGNOSTIC), + 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", entity_category=EntityCategory.CONFIG)) + 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", entity_category=EntityCategory.CONFIG)) + + + 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..01e7780 --- /dev/null +++ b/custom_components/ge_home/devices/ucim.py @@ -0,0 +1,42 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +from homeassistant.helpers.entity import Entity +from gehomesdk import ErdCode, ErdApplianceType + +from .base import ApplianceApi +from ..entities import ( + OimLightLevelOptionsConverter, + GeErdSensor, + GeErdBinarySensor, + GeErdSelect, + GeErdSwitch, + ErdOnOffBoolConverter +) + +_LOGGER = logging.getLogger(__name__) + + +class UcimApi(ApplianceApi): + """API class for Under Counter 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, entity_category=EntityCategory.DIAGNOSTIC), + GeErdBinarySensor(self, ErdCode.OIM_FILTER_STATUS, device_class_override="problem", entity_category=EntityCategory.DIAGNOSTIC), + GeErdBinarySensor(self, ErdCode.OIM_NEEDS_DESCALING, device_class_override="problem", entity_category=EntityCategory.DIAGNOSTIC), + GeErdSelect(self, ErdCode.OIM_LIGHT_LEVEL, OimLightLevelOptionsConverter(), entity_category=EntityCategory.CONFIG), + 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, entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.UCIM_CLEAN_STATUS, entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.UCIM_FILTER_PERCENTAGE_USED, entity_category=EntityCategory.DIAGNOSTIC), + GeErdBinarySensor(self, ErdCode.UCIM_BIN_FULL, device_class_override="problem", entity_category=EntityCategory.DIAGNOSTIC), + ] + + 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..04c4021 --- /dev/null +++ b/custom_components/ge_home/devices/wac.py @@ -0,0 +1,34 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +from homeassistant.helpers.entity import Entity +from gehomesdk 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, entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.AC_AMBIENT_TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.AC_FAN_SETTING, icon_override="mdi:fan", entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.AC_OPERATION_MODE, entity_category=EntityCategory.DIAGNOSTIC), + 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", entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.WAC_DEMAND_RESPONSE_STATE, entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.WAC_DEMAND_RESPONSE_POWER, uom_override="kW", entity_category=EntityCategory.DIAGNOSTIC), + ] + 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..810f059 --- /dev/null +++ b/custom_components/ge_home/devices/washer.py @@ -0,0 +1,71 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +from homeassistant.helpers.entity import Entity +from gehomesdk import ErdCode, ErdApplianceType + +from .base import ApplianceApi +from ..entities import GeErdSensor, GeErdBinarySensor, GeErdPropertySensor +from ..entities.laundry.ge_washer_cycle_button import GeWasherCycleButton + + +_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, entity_category=EntityCategory.DIAGNOSTIC), + 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, entity_category=EntityCategory.DIAGNOSTIC), + GeErdBinarySensor(self, ErdCode.LAUNDRY_REMOTE_STATUS, entity_category=EntityCategory.DIAGNOSTIC), + ] + + washer_entities = self.get_washer_entities() + + washer_entities.append(GeWasherCycleButton(self)) + + entities = base_entities + common_entities + washer_entities + return entities + + def get_washer_entities(self) -> List[Entity]: + washer_entities: List[Entity] = [ + GeErdSensor(self, ErdCode.LAUNDRY_WASHER_SOIL_LEVEL, icon_override="mdi:emoticon-poop", entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.LAUNDRY_WASHER_WASHTEMP_LEVEL, entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.LAUNDRY_WASHER_SPINTIME_LEVEL, icon_override="mdi:speedometer", entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.LAUNDRY_WASHER_RINSE_OPTION, icon_override="mdi:shimmer", entity_category=EntityCategory.DIAGNOSTIC), + ] + + if self.has_erd_code(ErdCode.LAUNDRY_WASHER_DOOR_LOCK): + washer_entities.extend([GeErdBinarySensor(self, ErdCode.LAUNDRY_WASHER_DOOR_LOCK, entity_category=EntityCategory.DIAGNOSTIC)]) + if self.has_erd_code(ErdCode.LAUNDRY_WASHER_TANK_STATUS): + washer_entities.extend([GeErdSensor(self, ErdCode.LAUNDRY_WASHER_TANK_STATUS, entity_category=EntityCategory.DIAGNOSTIC)]) + if self.has_erd_code(ErdCode.LAUNDRY_WASHER_TANK_SELECTED): + washer_entities.extend([GeErdSensor(self, ErdCode.LAUNDRY_WASHER_TANK_SELECTED, entity_category=EntityCategory.DIAGNOSTIC)]) + 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", entity_category=EntityCategory.DIAGNOSTIC)]) + 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", entity_category=EntityCategory.DIAGNOSTIC)]) + + 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", entity_category=EntityCategory.DIAGNOSTIC)]) + + if self.has_erd_code(ErdCode.LAUNDRY_WASHER_TUMBLECARE): + washer_entities.extend([GeErdBinarySensor(self, ErdCode.LAUNDRY_WASHER_TUMBLECARE, entity_category=EntityCategory.DIAGNOSTIC)]) + 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", entity_category=EntityCategory.DIAGNOSTIC)]) + + if self.has_erd_code(ErdCode.LAUNDRY_WASHER_SMART_DISPENSE_TANK_STATUS): + washer_entities.extend([GeErdSensor(self, ErdCode.LAUNDRY_WASHER_SMART_DISPENSE_TANK_STATUS, entity_category=EntityCategory.DIAGNOSTIC)]) + + return washer_entities \ No newline at end of file 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..af9195e --- /dev/null +++ b/custom_components/ge_home/devices/washer_dryer.py @@ -0,0 +1,39 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +from homeassistant.helpers.entity import Entity +from gehomesdk import ErdCode, ErdApplianceType + +from .washer import WasherApi +from .dryer import DryerApi +from ..entities import GeErdSensor, GeErdBinarySensor +from ..entities.laundry.ge_washer_cycle_button import GeWasherCycleButton + +_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, entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.LAUNDRY_CYCLE), + GeErdSensor(self, ErdCode.LAUNDRY_SUB_CYCLE), + 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, entity_category=EntityCategory.DIAGNOSTIC), + GeErdBinarySensor(self, ErdCode.LAUNDRY_REMOTE_STATUS, entity_category=EntityCategory.DIAGNOSTIC), + GeWasherCycleButton(self), + ] + + 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..63e9277 --- /dev/null +++ b/custom_components/ge_home/devices/water_filter.py @@ -0,0 +1,39 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +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, entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.WH_FILTER_VALVE_STATE, icon_override="mdi:state-machine", entity_category=EntityCategory.DIAGNOSTIC), + GeErdFilterPositionSelect(self, ErdCode.WH_FILTER_POSITION), + GeErdBinarySensor(self, ErdCode.WH_FILTER_MANUAL_MODE, icon_on_override="mdi:human", icon_off_override="mdi:robot", entity_category=EntityCategory.DIAGNOSTIC), + GeErdBinarySensor(self, ErdCode.WH_FILTER_LEAK_VALIDITY, device_class_override="moisture", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.WH_FILTER_FLOW_RATE, "flow_rate", entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.WH_FILTER_DAY_USAGE, device_class_override="water", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.WH_FILTER_LIFE_REMAINING, "life_remaining", entity_category=EntityCategory.DIAGNOSTIC), + GeErdBinarySensor(self, ErdCode.WH_FILTER_FLOW_ALERT, device_class_override="moisture", entity_category=EntityCategory.DIAGNOSTIC), + ] + 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..7ff9524 --- /dev/null +++ b/custom_components/ge_home/devices/water_heater.py @@ -0,0 +1,51 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +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, + 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 | None = self.try_get_erd_value(ErdCode.WH_HEATER_BOOST_STATE) + active: ErdOnOff | None = self.try_get_erd_value(ErdCode.WH_HEATER_ACTIVE_STATE) + + wh_entities = [ + GeErdSensor(self, ErdCode.WH_HEATER_TARGET_TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.WH_HEATER_TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.WH_HEATER_MODE_HOURS_REMAINING, entity_category=EntityCategory.DIAGNOSTIC, suggested_uom="h"), + GeErdSensor(self, ErdCode.WH_HEATER_ELECTRIC_MODE_MAX_TIME, entity_category=EntityCategory.DIAGNOSTIC, suggested_uom="h"), + GeErdSensor(self, ErdCode.WH_HEATER_VACATION_MODE_MAX_TIME, entity_category=EntityCategory.DIAGNOSTIC, suggested_uom="h"), + GeWaterHeater(self) + ] + + if(boost_mode and boost_mode != ErdOnOff.NA): + wh_entities.append(GeErdSwitch(self, ErdCode.WH_HEATER_BOOST_STATE, ErdOnOffBoolConverter(), icon_on_override="mdi:rocket-launch", icon_off_override="mdi:rocket-launch-outline", control_erd_code=ErdCode.WH_HEATER_BOOST_CONTROL, entity_category=EntityCategory.CONFIG)) + + if(active and active != ErdOnOff.NA): + wh_entities.append(GeErdSwitch(self, ErdCode.WH_HEATER_ACTIVE_STATE, ErdOnOffBoolConverter(), icon_on_override="mdi:power", icon_off_override="mdi:power-standby", control_erd_code=ErdCode.WH_HEATER_ACTIVE_CONTROL, entity_category=EntityCategory.CONFIG)) + + 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..cdd3621 --- /dev/null +++ b/custom_components/ge_home/devices/water_softener.py @@ -0,0 +1,39 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +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", entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertySensor(self, ErdCode.WH_FILTER_FLOW_RATE, "flow_rate", entity_category=EntityCategory.DIAGNOSTIC), + GeErdBinarySensor(self, ErdCode.WH_FILTER_FLOW_ALERT, device_class_override="moisture", entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.WH_FILTER_DAY_USAGE, device_class_override="water", entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.WH_SOFTENER_ERROR_CODE, icon_override="mdi:alert-circle", entity_category=EntityCategory.DIAGNOSTIC), + GeErdBinarySensor(self, ErdCode.WH_SOFTENER_LOW_SALT, icon_on_override="mdi:alert", icon_off_override="mdi:grain", entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.WH_SOFTENER_SHUTOFF_VALVE_STATE, icon_override="mdi:state-machine", entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.WH_SOFTENER_SALT_LIFE_REMAINING, icon_override="mdi:calendar-clock", entity_category=EntityCategory.DIAGNOSTIC), + 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..8653a21 --- /dev/null +++ b/custom_components/ge_home/entities/ac/fan_mode_options.py @@ -0,0 +1,47 @@ +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]: + mapped = { + 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) + + if(isinstance(mapped, ErdAcFanSetting)): + return mapped.stringify() + + _LOGGER.warning(f"Could not determine fan mode mapping for {value}") + 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..184248e --- /dev/null +++ b/custom_components/ge_home/entities/ac/ge_biac_climate.py @@ -0,0 +1,48 @@ +import logging +from typing import Any, List, Optional + +from homeassistant.components.climate.const 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: + hvac = HVACMode(value.lower()) + return { + HVACMode.AUTO: ErdAcOperationMode.ENERGY_SAVER, + HVACMode.COOL: ErdAcOperationMode.COOL, + HVACMode.FAN_ONLY: ErdAcOperationMode.FAN_ONLY + }.get(hvac) + + except ValueError: + _LOGGER.warning(f"Could not set HVAC mode to {value.upper()}") + return ErdAcOperationMode.COOL + + def to_option_string(self, value: Any) -> Optional[str]: + mapped = { + ErdAcOperationMode.ENERGY_SAVER: HVACMode.AUTO, + ErdAcOperationMode.AUTO: HVACMode.AUTO, + ErdAcOperationMode.COOL: HVACMode.COOL, + ErdAcOperationMode.FAN_ONLY: HVACMode.FAN_ONLY + }.get(value) + + if(isinstance(mapped, HVACMode)): + return mapped + + _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..d42aad7 --- /dev/null +++ b/custom_components/ge_home/entities/ac/ge_pac_climate.py @@ -0,0 +1,78 @@ +import logging +from propcache.api import cached_property +from typing import Any, List, Optional + +from homeassistant.components.climate.const import HVACMode +from gehomesdk import ErdCode, ErdAcOperationMode, ErdAcAvailableModes, 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: Optional[ErdAcAvailableModes] = None): + 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 [i.value for i in modes] + + def from_option_string(self, value: str) -> Any: + try: + hvac = HVACMode(value.lower()) + return { + HVACMode.COOL: ErdAcOperationMode.COOL, + HVACMode.HEAT: ErdAcOperationMode.HEAT, + HVACMode.FAN_ONLY: ErdAcOperationMode.FAN_ONLY, + HVACMode.DRY: ErdAcOperationMode.DRY + }.get(hvac) + except ValueError: + _LOGGER.warning(f"Could not set HVAC mode to {value.upper()}") + return ErdAcOperationMode.COOL + + def to_option_string(self, value: Any) -> Optional[str]: + mapped = { + ErdAcOperationMode.COOL: HVACMode.COOL, + ErdAcOperationMode.HEAT: HVACMode.HEAT, + ErdAcOperationMode.DRY: HVACMode.DRY, + ErdAcOperationMode.FAN_ONLY: HVACMode.FAN_ONLY + }.get(value) + + if(isinstance(mapped, HVACMode)): + return mapped + + _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): + #get a couple ERDs that shouldn't change if available + self._modes: ErdAcAvailableModes | None = api.try_get_erd_value(ErdCode.AC_AVAILABLE_MODES) + self._temp_range: ErdSacTargetTemperatureRange | None = api.try_get_erd_value(ErdCode.SAC_TARGET_TEMPERATURE_RANGE) + + #initialize the climate control with defaults + super().__init__(api, PacHvacModeOptionsConverter(self._modes), AcFanOnlyFanModeOptionsConverter(), AcFanOnlyFanModeOptionsConverter()) + + @cached_property + def min_temp(self) -> float: + temp = 64 + if self._temp_range: + temp = self._temp_range.min + return self._convert_temp(temp) + + @cached_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..dbbe943 --- /dev/null +++ b/custom_components/ge_home/entities/ac/ge_sac_climate.py @@ -0,0 +1,80 @@ +import logging +from propcache.api import cached_property +from typing import Any, List, Optional + +from homeassistant.components.climate.const import HVACMode +from gehomesdk import ErdCode, ErdAcOperationMode, ErdAcAvailableModes, 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: Optional[ErdAcAvailableModes] = None): + 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 [i.value for i in modes] + + def from_option_string(self, value: str) -> Any: + try: + hvac = HVACMode(value.lower()) + return { + HVACMode.AUTO: ErdAcOperationMode.AUTO, + HVACMode.COOL: ErdAcOperationMode.COOL, + HVACMode.HEAT: ErdAcOperationMode.HEAT, + HVACMode.FAN_ONLY: ErdAcOperationMode.FAN_ONLY, + HVACMode.DRY: ErdAcOperationMode.DRY + }.get(hvac) + except ValueError: + _LOGGER.warning(f"Could not set HVAC mode to {value.upper()}") + return ErdAcOperationMode.COOL + + def to_option_string(self, value: Any) -> Optional[str]: + mapped = { + 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) + + if(isinstance(mapped, HVACMode)): + return mapped + + _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): + #get a couple ERDs that shouldn't change if available + self._modes: ErdAcAvailableModes | None = api.try_get_erd_value(ErdCode.AC_AVAILABLE_MODES) + self._temp_range: ErdSacTargetTemperatureRange | None = api.try_get_erd_value(ErdCode.SAC_TARGET_TEMPERATURE_RANGE) + + #initialize the climate control + super().__init__(api, SacHvacModeOptionsConverter(self._modes), AcFanModeOptionsConverter(), AcFanOnlyFanModeOptionsConverter()) + + @cached_property + def min_temp(self) -> float: + temp = 60 + if self._temp_range: + temp = self._temp_range.min + return self._convert_temp(temp) + + @cached_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..7bab345 --- /dev/null +++ b/custom_components/ge_home/entities/ac/ge_wac_climate.py @@ -0,0 +1,61 @@ +import logging +from typing import Any, List, Optional + +from homeassistant.components.climate.const import HVACMode +from gehomesdk import ErdAcOperationMode, ErdCode, ErdAcAvailableModes + +from ...devices import ApplianceApi +from ..common import GeClimate, OptionsConverter +from .fan_mode_options import AcFanModeOptionsConverter, AcFanOnlyFanModeOptionsConverter + +_LOGGER = logging.getLogger(__name__) + +class WacHvacModeOptionsConverter(OptionsConverter): + def __init__(self, available_modes: Optional[ErdAcAvailableModes] = None): + self._available_modes = available_modes + + @property + def options(self) -> List[str]: + modes = [HVACMode.AUTO, HVACMode.COOL, HVACMode.DRY, HVACMode.FAN_ONLY] + if self._available_modes and self._available_modes.has_heat: + modes.append(HVACMode.HEAT) + return [i.value for i in modes] + + def from_option_string(self, value: str) -> Any: + try: + hvac = HVACMode(value.lower()) + return { + HVACMode.AUTO: ErdAcOperationMode.ENERGY_SAVER, + HVACMode.COOL: ErdAcOperationMode.COOL, + HVACMode.HEAT: ErdAcOperationMode.HEAT, + HVACMode.DRY: ErdAcOperationMode.DRY, + HVACMode.FAN_ONLY: ErdAcOperationMode.FAN_ONLY + }.get(hvac) + except ValueError: + _LOGGER.warning(f"Could not set HVAC mode to {value.upper()}") + return ErdAcOperationMode.COOL + + def to_option_string(self, value: Any) -> Optional[str]: + mapped = { + 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) + + if(isinstance(mapped, HVACMode)): + return mapped + + _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): + #get the available modes + self._modes: ErdAcAvailableModes | None = api.try_get_erd_value(ErdCode.AC_AVAILABLE_MODES) + + super().__init__(api, WacHvacModeOptionsConverter(self._modes), 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..8721b3c --- /dev/null +++ b/custom_components/ge_home/entities/advantium/ge_advantium.py @@ -0,0 +1,305 @@ +"""GE Home Sensor Entities - Advantium""" +import logging +from propcache.api import cached_property +from typing import Any, List, Mapping, Optional, cast +from random import randrange + +from homeassistant.const import ATTR_TEMPERATURE +from gehomesdk import ( + ErdCode, + ErdPersonality, + ErdAdvantiumCookStatus, + ErdAdvantiumCookSetting, + AdvantiumOperationMode, + AdvantiumCookSetting, + AdvantiumCookAction, + AdvantiumCookMode, + AdvantiumWarmStatus, + ErdAdvantiumRemoteCookModeConfig, + ADVANTIUM_OPERATION_MODE_COOK_SETTING_MAPPING +) + +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""" + + def __init__(self, api: ApplianceApi): + super().__init__(api) + self._current_operation_mode = None + + @property + def icon(self) -> Optional[str]: + return "mdi:microwave" + + @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 + + @cached_property + def unique_id(self) -> str: + return f"{DOMAIN}_{self.serial_number}" + + @cached_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) -> int | None: # type: ignore + return self.appliance.get_erd_value(ErdCode.UPPER_OVEN_DISPLAY_TEMPERATURE) + + @property + def current_operation(self) -> Optional[str]: # type: ignore + if self.current_operation_mode is None: + return None + + try: + return self.current_operation_mode.stringify() + except: + return None + + @cached_property + def operation_list(self) -> List[str]: + invalid = [] + if not self._remote_config.broil_enable: + invalid.append(AdvantiumCookMode.BROIL) + if not self._remote_config.convection_bake_enable: + invalid.append(AdvantiumCookMode.CONVECTION_BAKE) + if not self._remote_config.proof_enable: + invalid.append(AdvantiumCookMode.PROOF) + if not self._remote_config.warm_enable: + invalid.append(AdvantiumCookMode.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 cast(ErdAdvantiumCookSetting, self.appliance.get_erd_value(ErdCode.ADVANTIUM_COOK_SETTING)) + + @property + def current_cook_status(self) -> ErdAdvantiumCookStatus: + """Get the current status.""" + return cast(ErdAdvantiumCookStatus, self.appliance.get_erd_value(ErdCode.ADVANTIUM_COOK_STATUS)) + + @property + def current_operation_mode(self) -> AdvantiumOperationMode | None: + """Gets the current operation mode""" + self._ensure_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.warning(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""" + + if self.current_operation_setting is None: + return False + try: + return self.current_operation_setting.allow_temperature_set + except: + return False + + @property + def target_temperature(self) -> int | None: # type: ignore + """Return the temperature we try to reach.""" + try: + cook_mode = self.current_cook_setting + if ( + cook_mode.cook_mode != AdvantiumCookMode.NO_MODE and + 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) -> Mapping[str, Any] | None: # type: ignore + 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.current_cook_status.cook_mode == setting.cook_mode and + self.target_temperature): + target_temp = max(self.min_temp, min(self.max_temp, int(self.target_temperature))) + + #by default we will start an operation, but handle other actions too + action = AdvantiumCookAction.START + if mode == AdvantiumOperationMode.OFF: + action = AdvantiumCookAction.STOP + elif self.current_cook_setting.cook_action == AdvantiumCookAction.PAUSE: + action = AdvantiumCookAction.RESUME + elif self.current_cook_setting.cook_action in [AdvantiumCookAction.START, AdvantiumCookAction.RESUME]: + action = AdvantiumCookAction.UPDATED + + #construct the new mode based on the existing mode + new_cook_mode = ErdAdvantiumCookSetting( + d=randrange(255), + cook_action=action, + cook_mode=setting.cook_mode, + target_temperature=target_temp or 0, + power_level=setting.target_power_level or 0, + warm_status=setting.warm_status or AdvantiumWarmStatus.OFF, + ) + _LOGGER.debug("New ErdAdvantiumCookSetting: %s", new_cook_mode) + + 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 = AdvantiumCookAction.UPDATED + + #construct the new mode based on the existing mode + current_cook_mode = self.current_cook_setting + new_cook_mode = current_cook_mode._replace( + d = randrange(255), + target_temperature = target_temp, + cook_action = action, + ) + + await self.appliance.async_set_erd_value(ErdCode.ADVANTIUM_COOK_SETTING, new_cook_mode) + + def _ensure_operation_mode(self): + cook_status = self.current_cook_status + cook_mode = cook_status.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 + self._current_operation_mode = None + + #synchronize the operation mode with the device state + if cook_mode == AdvantiumCookMode.MICROWAVE: + #microwave matches on cook mode and power level + if cook_status.power_level == 3: + self._current_operation_mode = AdvantiumOperationMode.MICROWAVE_PL3 + elif cook_status.power_level == 5: + self._current_operation_mode = AdvantiumOperationMode.MICROWAVE_PL5 + elif cook_status.power_level == 7: + self._current_operation_mode = AdvantiumOperationMode.MICROWAVE_PL7 + else: + self._current_operation_mode = AdvantiumOperationMode.MICROWAVE_PL10 + elif cook_mode == AdvantiumCookMode.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_status.warm_status == value.warm_status and + cook_status.temperature == self._convert_target_temperature( + value.target_temperature_120v_f, value.target_temperature_240v_f)): + self._current_operation_mode = key + break + + #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 + break + + _LOGGER.debug("Operation mode is set to %s", self._current_operation_mode) + return + + def _convert_target_temperature(self, temp_120v: Optional[int], temp_240v: Optional[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 + return target_temp_f + + async def async_device_update(self, warning: bool = True) -> None: + await super().async_device_update(warning=warning) + 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..693d0eb --- /dev/null +++ b/custom_components/ge_home/entities/ccm/ge_ccm_brew_cups.py @@ -0,0 +1,22 @@ +from homeassistant.const import EntityCategory +from homeassistant.components.number import NumberMode +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=NumberMode.BOX, entity_category=EntityCategory.DIAGNOSTIC) + 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) -> int: # type: ignore + return int(self.get_value(device_value = super().native_value) or 0.0) 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..bb6a5d5 --- /dev/null +++ b/custom_components/ge_home/entities/ccm/ge_ccm_brew_settings.py @@ -0,0 +1,16 @@ +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.""" + + from ...devices import CcmApi + + # Forward the call up to the Coffee Maker device to handle + if isinstance(self.api, CcmApi): + 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..34da572 --- /dev/null +++ b/custom_components/ge_home/entities/ccm/ge_ccm_brew_strength.py @@ -0,0 +1,50 @@ +import logging +from typing import List, Any, Optional + +from homeassistant.const import EntityCategory +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__) + +DEFAULT_BREW_STRENGTH = ErdCcmBrewStrength.MEDIUM + +class GeCcmBrewStrengthOptionsConverter(OptionsConverter): + def __init__(self): + self._default = DEFAULT_BREW_STRENGTH + + @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(), entity_category=EntityCategory.CONFIG) + GeCcmCachedValue.__init__(self) + + @property + def brew_strength(self) -> ErdCcmBrewStrength: + return self._converter.from_option_string(self.current_option or DEFAULT_BREW_STRENGTH.name) + + async def async_select_option(self, option): + GeCcmCachedValue.set_value(self, option) + self.schedule_update_ha_state() + + @property + def current_option(self) -> str | None: # type: ignore + 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..da8eae7 --- /dev/null +++ b/custom_components/ge_home/entities/ccm/ge_ccm_brew_temperature.py @@ -0,0 +1,29 @@ +from homeassistant.const import EntityCategory +from homeassistant.components.number import NumberMode +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=NumberMode.SLIDER, entity_category=EntityCategory.DIAGNOSTIC) + 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) -> int: # type: ignore + return int(self.get_value(device_value = super().native_value) or self.min_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..32bd68c --- /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, value): + self._set_value = 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..5ee1355 --- /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: # type: ignore + """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..3b7bf9e --- /dev/null +++ b/custom_components/ge_home/entities/common/__init__.py @@ -0,0 +1,18 @@ +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_cooktop_status_binary_sensor import GeCooktopStatusBinarySensor +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 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..d1dc9c4 --- /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() or False + 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..06aa63f --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_climate.py @@ -0,0 +1,222 @@ +import logging +from propcache.api import cached_property +from typing import List, Optional + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ClimateEntityFeature, HVACMode +from homeassistant.const import ( + ATTR_TEMPERATURE, + UnitOfTemperature, +) +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: Optional[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, + target_heating_temperature_erd_code: ErdCodeType = ErdCode.AC_TARGET_HEATING_TEMPERATURE, + + ): + 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) + self._target_heating_temperature_erd_code = api.appliance.translate_erd_code(target_heating_temperature_erd_code) + + self._has_heat_erd_code = api.try_get_erd_value(self._target_heating_temperature_erd_code) is not None + + @cached_property + def unique_id(self) -> str: + return f"{DOMAIN}_{self.serial_or_mac}_climate" + + @cached_property + def name(self) -> Optional[str]: + return f"{self.serial_or_mac} Climate" + + @property + def icon(self) ->str | None: # type: ignore + return self._get_icon() + + @property + def available(self) -> bool: # type: ignore + return self.api.available + + @property + def power_status_erd_code(self): + return self._power_status_erd_code + + @property + def target_temperature_erd_code(self): + if self.hvac_mode == HVACMode.HEAT and self._has_heat_erd_code: + return self._target_heating_temperature_erd_code + 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 + + @cached_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 + + @cached_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) -> float | None: # type: ignore + 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) -> float | None: # type: ignore + 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)) + + @cached_property + def min_temp(self) -> float: + return self._convert_temp(64) + + @cached_property + def max_temp(self) -> float: + return self._convert_temp(86) + + @property + def hvac_mode(self) -> HVACMode | None: # type: ignore + if not self.is_on: + return HVACMode.OFF + try: + hm = self._hvac_mode_converter.to_option_string(self.appliance.get_erd_value(self.hvac_mode_erd_code)) + return HVACMode(hm) + except: + return None + + @cached_property + def hvac_modes(self) -> List[HVACMode]: + return [HVACMode.OFF] + [HVACMode(m) for m in self._hvac_mode_converter.options] + + @property + def fan_mode(self) -> str | None: # type: ignore + 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)) + + @cached_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_cooktop_status_binary_sensor.py b/custom_components/ge_home/entities/common/ge_cooktop_status_binary_sensor.py new file mode 100644 index 0000000..be4395e --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_cooktop_status_binary_sensor.py @@ -0,0 +1,33 @@ +from typing import Optional + +from gehomesdk import CooktopStatus + +from .ge_erd_binary_sensor import GeErdBinarySensor + + +class GeCooktopStatusBinarySensor(GeErdBinarySensor): + """Binary sensor that reports if any cooktop burner is active.""" + + @property + def is_on(self) -> Optional[bool]: + """Return True when any burner that exists is reported as on.""" + try: + status = self.appliance.get_erd_value(self.erd_code) + except KeyError: + return None + + if status is None: + return None + + if not isinstance(status, CooktopStatus): + return self._boolify(status) + + burners = status.burners or {} + + for burner in burners.values(): + exists = getattr(burner, "exists", False) + is_on = self._boolify(getattr(burner, "on", None)) + if exists and is_on: + return True + + return status.boolify() 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..1835bff --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_entity.py @@ -0,0 +1,88 @@ +from datetime import timedelta +from propcache.api import cached_property +from typing import Optional, Any + +from homeassistant.helpers.device_registry import DeviceInfo + +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 + + @cached_property + def unique_id(self) -> str | None: + raise NotImplementedError + + @property + def api(self) -> ApplianceApi: + return self._api + + @cached_property + def device_info(self) -> DeviceInfo | None: + 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 + + @cached_property + def name(self) -> Optional[str]: + raise NotImplementedError + + @property + def icon(self) ->str | None: # type: ignore + return self._get_icon() + + @cached_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..cc21fae --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_binary_sensor.py @@ -0,0 +1,71 @@ +from propcache.api import cached_property +from typing import Optional + +from homeassistant.const import EntityCategory +from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass +from gehomesdk import ErdCodeType, ErdCodeClass + +from ...devices import ApplianceApi +from .ge_erd_entity import GeErdEntity + + +class GeErdBinarySensor(GeErdEntity, BinarySensorEntity): + """GE Entity for binary sensors""" + + def __init__( + self, + api: ApplianceApi, + erd_code: ErdCodeType, + erd_override: Optional[str] = None, + icon_on_override: Optional[str] = None, + icon_off_override: Optional[str] = None, + device_class_override: Optional[str] = None, + entity_category: Optional[EntityCategory] = None + ): + super().__init__(api, erd_code, erd_override, icon_on_override, device_class_override, entity_category) + self._icon_on_override = icon_on_override + self._icon_off_override = icon_off_override + + @property + def icon(self) ->str | None: # type: ignore + return super().icon + + @property + def available(self) -> bool: # type: ignore + return super().available + + @property + def is_on(self) -> bool | None: # type: ignore + """Return True if entity is on.""" + return self._boolify(self.appliance.get_erd_value(self.erd_code)) + + @cached_property + def device_class(self) -> BinarySensorDeviceClass | None: + # Use GeEntity’s logic, but adapt to HA’s BinarySensorDeviceClass expectations + dc = super(GeErdEntity, self).device_class # call GeEntity version + + if isinstance(dc, str): + try: + return BinarySensorDeviceClass(dc) + except ValueError: + return None + + return dc + + 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..775aa6c --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_button.py @@ -0,0 +1,41 @@ +from propcache.api import cached_property +from typing import Optional + +from homeassistant.const import EntityCategory +from homeassistant.components.button import ButtonEntity, ButtonDeviceClass + +from gehomesdk import ErdCodeType +from ...devices import ApplianceApi +from .ge_erd_entity import GeErdEntity + + +class GeErdButton(GeErdEntity, ButtonEntity): + """GE Entity for buttons""" + + def __init__(self, api: ApplianceApi, erd_code: ErdCodeType, erd_override: Optional[str] = None, entity_category: Optional[EntityCategory] = None): + super().__init__(api, erd_code, erd_override=erd_override, entity_category=entity_category) + + @property + def icon(self) ->str | None: # type: ignore + return super().icon + + @property + def available(self) -> bool: # type: ignore + return super().available + + @cached_property + def device_class(self) -> ButtonDeviceClass | None: + # Use GeEntity’s logic, but adapt to HA’s ButtonDeviceClass expectations + dc = super(GeErdEntity, self).device_class # call GeEntity version + + if isinstance(dc, str): + try: + return ButtonDeviceClass(dc) + except ValueError: + return None + + return dc + + 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..34df9e6 --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_entity.py @@ -0,0 +1,157 @@ +from datetime import timedelta +from propcache.api import cached_property +from typing import Optional, Any + +from homeassistant.const import EntityCategory +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: Optional[str] = None, + icon_override: Optional[str] = None, + device_class_override: Optional[str] = None, + entity_category: Optional[EntityCategory] = 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 + self._attr_entity_category = entity_category + + 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(erd_code, ErdCode): + return erd_code.name + return str(erd_code) + + @cached_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}" + + @cached_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..cad9380 --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_light.py @@ -0,0 +1,77 @@ +import logging +from propcache.api import cached_property +from typing import Optional + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + LightEntity +) +from homeassistant.components.light.const import ( + ColorMode +) +from homeassistant.const import EntityCategory +from gehomesdk import ErdCodeType + +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: Optional[str] = None, color_mode: ColorMode = ColorMode.BRIGHTNESS, entity_category: Optional[EntityCategory] = None): + super().__init__(api, erd_code, erd_override, entity_category=entity_category) + self._color_mode = color_mode + + @property + def icon(self) ->str | None: # type: ignore + return super().icon + + @property + def available(self) -> bool: # type: ignore + return super().available + + @cached_property + def supported_color_modes(self) -> set[ColorMode]: + """Flag supported color modes.""" + return set([ColorMode.BRIGHTNESS]) + + @property + def color_mode(self) -> ColorMode: # type: ignore + """Return the color mode of the light.""" + return self._color_mode + + @property + def brightness(self): # type: ignore + """Return the brightness of the light.""" + return to_hass_level(self.appliance.get_erd_value(self.erd_code)) + + @property + def is_on(self) -> bool: # type: ignore + """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) + + async def _set_brightness(self, brightness, **kwargs): + await self.appliance.async_set_erd_value(self.erd_code, to_ge_level(brightness)) + 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..76d4103 --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_number.py @@ -0,0 +1,146 @@ +import logging +from propcache.api import cached_property +from typing import Optional + +from homeassistant.components.number import ( + NumberEntity, + NumberMode, + NumberDeviceClass, +) +from homeassistant.const import UnitOfTemperature, EntityCategory +from gehomesdk import ErdCodeType, ErdCodeClass, ErdDataType + +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: Optional[str] = None, + icon_override: Optional[str] = None, + device_class_override: Optional[str] = None, + uom_override: Optional[str] = None, + data_type_override: Optional[ErdDataType] = None, + min_value: float = 1, + max_value: float = 100, + step_value: float = 1, + mode: NumberMode = NumberMode.AUTO, + entity_category: Optional[EntityCategory] = None + ): + super().__init__(api, erd_code, erd_override, icon_override, device_class_override, entity_category) + 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 icon(self) ->str | None: # type: ignore + return super().icon + + @property + def available(self) -> bool: # type: ignore + return super().available + + @property + def native_value(self) -> float | None: # type: ignore + try: + value = self.appliance.get_erd_value(self.erd_code) + return self._convert_value_from_device(value) + except KeyError: + return None + + @cached_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) + + @cached_property + def native_min_value(self) -> float: + return self._native_min_value + + @cached_property + def native_max_value(self) -> float: + return self._native_max_value + + @cached_property + def native_step(self) -> float: + return self._native_step + + @cached_property + def mode(self) -> NumberMode: + return self._mode + + @cached_property + def device_class(self) -> NumberDeviceClass | None: + # Use GeEntity’s logic, but adapt to HA’s NumberDeviceClass expectations + dc = super(GeErdEntity, self).device_class # call GeEntity version + + if isinstance(dc, str): + try: + return NumberDeviceClass(dc) + except ValueError: + return None + + return dc + + def _convert_value_from_device(self, value) -> float | None: + """Convert to expected data type""" + try: + if self._data_type == ErdDataType.INT: + return float(round(value)) + else: + return float(value) + except: + return None + + 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 + + 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..ef0689e --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_property_binary_sensor.py @@ -0,0 +1,55 @@ +from propcache.api import cached_property +from typing import Optional + +import magicattr +from homeassistant.const import EntityCategory +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: Optional[str] = None, + icon_on_override: Optional[str] = None, + icon_off_override: Optional[str] = None, + device_class_override: Optional[str] = None, + entity_category: Optional[EntityCategory] = None + ): + super().__init__(api, erd_code, erd_override, icon_on_override, icon_off_override, device_class_override, entity_category) + self.erd_property = erd_property + self._erd_property_cleansed = erd_property.replace(".","_").replace("[","_").replace("]","_") + + @cached_property + def unique_id(self) -> Optional[str]: + return f"{super().unique_id}_{self._erd_property_cleansed}" + + @cached_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 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) + + """ + Handle the property 'wifi_enabled' + May be safe for other appliances with UserSetting.ENABLE/DISABLE but not sure + """ + if self.erd_property == 'wifi_enabled': + if self._stringify(value) == 'Enable': + return True + else: + return False + + except KeyError: + return None + return self._boolify(value) 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..2e136fb --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_property_sensor.py @@ -0,0 +1,65 @@ +from propcache.api import cached_property +from typing import Optional +import magicattr + +from homeassistant.const import EntityCategory +from gehomesdk import ErdCodeType, 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: Optional[str] = None, + icon_override: Optional[str] = None, + device_class_override: Optional[str] = None, + state_class_override: Optional[str] = None, + uom_override: Optional[str] = None, + data_type_override: Optional[ErdDataType] = None, + entity_category: Optional[EntityCategory] = 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, + entity_category=entity_category + ) + self.erd_property = erd_property + self._erd_property_cleansed = erd_property.replace(".","_").replace("[","_").replace("]","_") + + @cached_property + def unique_id(self) -> Optional[str]: + return f"{super().unique_id}_{self._erd_property_cleansed}" + + @cached_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) -> str | float | int | None: # type: ignore + 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..a6164a7 --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_select.py @@ -0,0 +1,61 @@ + +import logging +from propcache.api import cached_property +from typing import Any, List, Optional + +from homeassistant.const import EntityCategory +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""" + + def __init__( + self, + api: ApplianceApi, + erd_code: ErdCodeType, + converter: OptionsConverter, + erd_override: Optional[str] = None, + icon_override: Optional[str] = None, + control_erd_code: Optional[ErdCodeType] = None, + entity_category: Optional[EntityCategory] = None + ): + super().__init__(api, erd_code, erd_override=erd_override, icon_override=icon_override, entity_category=entity_category) + self._converter = converter + self._control_erd_code = control_erd_code + + @property + def icon(self) ->str | None: # type: ignore + return super().icon + + @property + def available(self) -> bool: # type: ignore + return super().available + + @property + def current_option(self) -> str | None: # type: ignore + return self._converter.to_option_string(self.appliance.get_erd_value(self.erd_code)) + + @cached_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._writeable_erd_code, self._converter.from_option_string(option)) + + @property + def _writeable_erd_code(self) -> ErdCodeType: + if self._control_erd_code: + return self._control_erd_code + + return self.erd_code \ No newline at end of file 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..b6a9249 --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_sensor.py @@ -0,0 +1,207 @@ +import logging +from datetime import timedelta +from propcache.api import cached_property +from typing import Optional + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass +from homeassistant.const import UnitOfTemperature, EntityCategory +from gehomesdk import ErdCodeType, ErdCodeClass, ErdDataType + +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: Optional[str] = None, + icon_override: Optional[str] = None, + device_class_override: Optional[str] = None, + state_class_override: Optional[str] = None, + uom_override: Optional[str] = None, + data_type_override: Optional[ErdDataType] = None, + entity_category: Optional[EntityCategory] = None, + suggested_uom: Optional[str] = None, + suggested_precision: Optional[int] = None + ): + super().__init__(api, erd_code, erd_override, icon_override, device_class_override, entity_category) + 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 icon(self) ->str | None: # type: ignore + return super().icon + + @property + def available(self) -> bool: # type: ignore + return super().available + + @property + def native_value(self) -> str | int | float | None: # type: ignore + 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, ValueError): + return None + + @cached_property + def native_unit_of_measurement(self) -> Optional[str]: + return self._get_uom() + + @cached_property + def suggested_unit_of_measurement(self) -> Optional[str]: + return self._suggested_uom + + @cached_property + def suggested_display_precision(self) -> Optional[int]: + return self._suggested_precision + + @cached_property + def state_class(self) -> Optional[str]: + return self._get_state_class() + + @cached_property + def device_class(self) -> SensorDeviceClass | None: + # Use GeEntity’s logic, but adapt to HA’s SensorDeviceClass expectations + dc = super(GeErdEntity, self).device_class # call GeEntity version + + if isinstance(dc, str): + try: + return SensorDeviceClass(dc) + except ValueError: + return None + + return dc + + @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_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 _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 _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 + + 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..8b22234 --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_switch.py @@ -0,0 +1,95 @@ +import logging +from propcache.api import cached_property +from typing import Optional + +from homeassistant.const import EntityCategory +from homeassistant.components.switch import SwitchEntity, SwitchDeviceClass +from gehomesdk import ErdCodeType + +from ...devices import ApplianceApi +from .ge_erd_binary_sensor import GeErdEntity +from .bool_converter import BoolConverter + +_LOGGER = logging.getLogger(__name__) + +class GeErdSwitch(GeErdEntity, SwitchEntity): + """Switches for boolean ERD codes.""" + + def __init__( + self, + api: ApplianceApi, + erd_code: ErdCodeType, + bool_converter: BoolConverter = BoolConverter(), + erd_override: Optional[str] = None, + icon_on_override: Optional[str] = None, + icon_off_override: Optional[str] = None, + device_class_override: Optional[str] = None, + control_erd_code: Optional[ErdCodeType] = None, + entity_category: Optional[EntityCategory] = None + ): + super().__init__(api, erd_code, erd_override, icon_on_override, device_class_override, entity_category) + self._icon_on_override = icon_on_override + self._icon_off_override = icon_off_override + self._converter = bool_converter + self._control_erd_code = control_erd_code + + @property + def icon(self) ->str | None: # type: ignore + return super().icon + + @property + def available(self) -> bool: # type: ignore + return super().available + + @property + def is_on(self) -> bool: # type: ignore + """Return True if switch is on.""" + return self._converter.boolify(self.appliance.get_erd_value(self.erd_code)) + + @cached_property + def device_class(self) -> SwitchDeviceClass | None: + dc = self._get_device_class() + + if dc is None: + return None + + if isinstance(dc, str): + try: + return SwitchDeviceClass(dc) + except ValueError: + return None + if isinstance(dc, SwitchDeviceClass): + return dc + + return None + + 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._writeable_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._writeable_erd_code, self._converter.false_value()) + + 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 + + return super()._get_icon() + + def _get_device_class(self) -> Optional[str]: + if self._device_class_override: + return self._device_class_override + return None + + @property + def _writeable_erd_code(self) -> ErdCodeType: + if self._control_erd_code: + return self._control_erd_code + + return self.erd_code 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..4b33b4e --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_timer_sensor.py @@ -0,0 +1,23 @@ +from datetime import timedelta +import logging + +from .ge_erd_sensor import GeErdSensor + +_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=True) + + 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=True) 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..c32a1fc --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_humidifier.py @@ -0,0 +1,114 @@ +import abc +import logging +from propcache.api import cached_property +from typing import 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 + + @cached_property + def unique_id(self) -> str: + return f"{DOMAIN}_{self.serial_or_mac}_{self._device_class}" + + @cached_property + def name(self) -> Optional[str]: + return f"{self.serial_or_mac} {self._device_class.title()}" + + @property + def icon(self) ->str | None: # type: ignore + return super().icon + + @property + def available(self) -> bool: # type: ignore + return super().available + + @property + def target_humidity(self) -> int | None: # type: ignore + return int(self.appliance.get_erd_value(self._target_humidity_erd_code)) + + @property + def current_humidity(self) -> int | None: # type: ignore + return int(self.appliance.get_erd_value(self._current_humidity_erd_code)) + + @cached_property + def min_humidity(self) -> int: + return self._range_min + + @cached_property + def max_humidity(self) -> int: + return self._range_max + + @cached_property + def supported_features(self) -> HumidifierEntityFeature: + return HumidifierEntityFeature(HumidifierEntityFeature.MODES) + + @property + def is_on(self) -> bool: # type: ignore + return self.appliance.get_erd_value(self._power_status_erd_code) == ErdOnOff.ON + + @cached_property + def device_class(self) -> HumidifierDeviceClass | None: + return self._device_class + + async def async_set_humidity(self, humidity: int) -> 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, **kwargs: Any): + await self.appliance.async_set_erd_value( + self._power_status_erd_code, ErdOnOff.ON + ) + + async def async_turn_off(self, **kwargs: Any): + 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..1637bb9 --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_water_heater.py @@ -0,0 +1,51 @@ +import abc +import logging +from propcache.api import cached_property +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 icon(self) ->str | None: # type: ignore + return super().icon + + @property + def available(self) -> bool: # type: ignore + return super().available + + @property + def heater_type(self) -> str: + raise NotImplementedError + + @cached_property + def operation_list(self) -> List[str]: + raise NotImplementedError + + @cached_property + def unique_id(self) -> str: + return f"{DOMAIN}_{self.serial_or_mac}_{self.heater_type}" + + @cached_property + def name(self) -> Optional[str]: + return f"{self.serial_or_mac} {self.heater_type.title()}" + + @cached_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..b98f101 --- /dev/null +++ b/custom_components/ge_home/entities/dehumidifier/dehumidifier.py @@ -0,0 +1,77 @@ +"""GE Home Dehumidifier""" +import logging +from propcache.api import cached_property +from typing import Optional + +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""" + + def __init__(self, api: ApplianceApi): + + #try to get the range + range: DehumidifierTargetRange | None = 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 icon(self) -> str | None: + return "mdi:air-humidifier" + + @cached_property + def supported_features(self) -> HumidifierEntityFeature: + if self._has_fan: + return HumidifierEntityFeature(HumidifierEntityFeature.MODES) + else: + return HumidifierEntityFeature(0) + + @property + def mode(self) -> str | None: # type: ignore + if not self._has_fan: + raise NotImplementedError() + + return self._mode_converter.to_option_string( + self.appliance.get_erd_value(ErdCode.AC_FAN_SETTING) + ) + + @cached_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..2c4fecf --- /dev/null +++ b/custom_components/ge_home/entities/dehumidifier/dehumidifier_fan_speed_sensor.py @@ -0,0 +1,44 @@ +from typing import Optional + +from homeassistant.const import EntityCategory +from gehomesdk import ErdCodeType, ErdDataType, ErdAcFanSetting + +from ...devices import ApplianceApi +from ..common import GeErdSensor +from .dehumidifier_fan_options import DehumidifierFanSettingOptionsConverter + +class GeDehumidifierFanSpeedSensor(GeErdSensor): + def __init__( + self, + api: ApplianceApi, + erd_code: ErdCodeType, + erd_override: Optional[str] = None, + icon_override: Optional[str] = None, + device_class_override: Optional[str] = None, + state_class_override: Optional[str] = None, + uom_override: Optional[str] = None, + data_type_override: Optional[ErdDataType] = None + ): + + super().__init__( + api, + erd_code, + erd_override, + icon_override, + device_class_override, + state_class_override, + uom_override, + data_type_override, + entity_category=EntityCategory.DIAGNOSTIC + ) + + self._converter = DehumidifierFanSettingOptionsConverter() + + @property + def native_value(self) -> str | None: + 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..6f03ed7 --- /dev/null +++ b/custom_components/ge_home/entities/dishwasher/__init__.py @@ -0,0 +1,2 @@ +from .ge_dishwasher_control_locked_switch import GeDishwasherControlLockedSwitch +from .ge_dishwasher_command_button import GeDishwasherCommandButton \ No newline at end of file diff --git a/custom_components/ge_home/entities/dishwasher/ge_dishwasher_command_button.py b/custom_components/ge_home/entities/dishwasher/ge_dishwasher_command_button.py new file mode 100644 index 0000000..8f37bbe --- /dev/null +++ b/custom_components/ge_home/entities/dishwasher/ge_dishwasher_command_button.py @@ -0,0 +1,33 @@ +from propcache.api import cached_property +from typing import Optional + +from gehomesdk import ErdCode, ErdRemoteCommand +from ...devices import ApplianceApi +from ..common import GeErdButton + +class GeDishwasherCommandButton(GeErdButton): + def __init__(self, api: ApplianceApi, erd_code: ErdCode, command: ErdRemoteCommand, erd_override: Optional[str] = None): + super().__init__(api, erd_code=erd_code, erd_override=erd_override) + self._command = command + self._command_cleansed = self._command.name.replace(".","_").replace("[","_").replace("]","_") + + @cached_property + def unique_id(self) -> str | None: + return f"{super().unique_id}_{self._command_cleansed}" + + @cached_property + def name(self) -> Optional[str]: + base_string = super().name + property_name = self._command_cleansed.replace("_", " ").title() + return f"{base_string} {property_name}" + + async def async_press(self) -> None: + """Handle the button press.""" + await self.appliance.async_set_erd_value(self.erd_code, self._command) + + def _get_icon(self) -> Optional[str]: + return { + ErdRemoteCommand.START_RESUME: "mdi:play", + ErdRemoteCommand.CANCEL: "mdi:stop", + ErdRemoteCommand.PAUSE: "mdi:pause" + }.get(self._command) \ 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..7ec2cbe --- /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.DISHWASHER_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..0703f82 --- /dev/null +++ b/custom_components/ge_home/entities/fridge/__init__.py @@ -0,0 +1,6 @@ +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 +from .ge_kcup_switch import GeKCupSwitch \ 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..88112a3 --- /dev/null +++ b/custom_components/ge_home/entities/fridge/convertable_drawer_mode_options.py @@ -0,0 +1,64 @@ +import logging +from typing import List, Any, Optional + +from homeassistant.const import UnitOfTemperature +from homeassistant.util.unit_system import UnitSystem +from gehomesdk import ErdConvertableDrawerMode + +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 [ + s + for i in ErdConvertableDrawerMode + if i not in self._excluded_options + for s in [self.to_option_string(i)] + if s is not None + ] + + 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..107a81f --- /dev/null +++ b/custom_components/ge_home/entities/fridge/ge_abstract_fridge.py @@ -0,0 +1,205 @@ +"""GE Home Sensor Entities - Abstract Fridge""" +import logging +from propcache.api import cached_property +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, + ErdCodeType, + ErdOnOff, + ErdFullNotFull, + FridgeDoorStatus, + FridgeSetPointLimits, + FridgeSetPoints, + FridgeIceBucketStatus, + IceMakerControlStatus +) + +from ...const import DOMAIN +from ...devices import ApplianceApi +from ..common import GeAbstractWaterHeater +from .const import * + +_LOGGER = logging.getLogger(__name__) + +class GeAbstractFridge(GeAbstractWaterHeater): + """Mock a fridge or freezer as a water heater.""" + + def __init__( + self, + api: ApplianceApi + ): + super().__init__(api) + + # 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. + self.temp_limits = {} + self.temp_limits["fridge_min"] = 32 + self.temp_limits["fridge_max"] = 46 + self.temp_limits["freezer_min"] = -6 + self.temp_limits["freezer_max"] = 7 + + @property + def heater_type(self) -> str: + raise NotImplementedError + + @property + def turbo_erd_code(self) -> ErdCodeType: + raise NotImplementedError + + @property + def turbo_mode(self) -> str: + raise NotImplementedError + + @cached_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] + + @cached_property + def unique_id(self) -> str: + return f"{DOMAIN}_{self.serial_number}_{self.heater_type}" + + @cached_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 | None: # type: ignore + """Return the temperature we try to reach.""" + return getattr(self.target_temps, self.heater_type) + + @property + def current_temperature(self) -> int | None: # type: ignore + """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: # type: ignore + """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_imbs: FridgeIceBucketStatus = self.appliance.get_erd_value(ErdCode.ICE_MAKER_BUCKET_STATUS) + ice_bucket_status = getattr(erd_imbs, 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_imc: IceMakerControlStatus = self.appliance.get_erd_value(ErdCode.ICE_MAKER_CONTROL) + ice_control_status = getattr(erd_imc, 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]: # type: ignore + 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..86fee0d --- /dev/null +++ b/custom_components/ge_home/entities/fridge/ge_dispenser.py @@ -0,0 +1,140 @@ +"""GE Home Sensor Entities - Dispenser""" + +import logging +from propcache.api import cached_property +from typing import List, 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 ...devices import ApplianceApi +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""" + + def __init__( + self, + api: ApplianceApi + ): + super().__init__(api) + + # 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 + self._min_temp = 90 + self._max_temp = 190 #185 + + @property + def heater_type(self) -> str: + return HEATER_TYPE_DISPENSER + + @property + def icon(self) ->str | None: + return "mdi:cup-water" + + @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 + + @cached_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: # type: ignore + """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) -> int | None: # type: ignore + """Return the current temperature.""" + return self.hot_water_status.current_temp + + @property + def target_temperature(self) -> int | None: # type: ignore + """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]: # type: ignore + 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..f89bb31 --- /dev/null +++ b/custom_components/ge_home/entities/fridge/ge_freezer.py @@ -0,0 +1,47 @@ +"""GE Home Sensor Entities - Freezer""" +import logging +from typing import Any, Dict + +from gehomesdk import ( + ErdCode, + ErdCodeType, + 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.""" + + @property + def heater_type(self) -> str: + return HEATER_TYPE_FREEZER + + @property + def icon(self) -> str | None: + return "mdi:fridge-top" + + @property + def turbo_erd_code(self) -> ErdCodeType: + return ErdCode.TURBO_FREEZE_STATUS + + @property + def turbo_mode(self) -> str: + return OP_MODE_TURBO_FREEZE + + @property + def door_state_attrs(self) -> 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..de586dd --- /dev/null +++ b/custom_components/ge_home/entities/fridge/ge_fridge.py @@ -0,0 +1,76 @@ +"""GE Home Sensor Entities - Fridge""" +import logging +from typing import Any, Dict + +from gehomesdk import ( + ErdCode, + ErdCodeType, + 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): + + + @property + def heater_type(self) -> str: + return HEATER_TYPE_FRIDGE + + @property + def icon(self) -> str | None: + return "mdi:fridge-bottom" + + @property + def turbo_erd_code(self) -> ErdCodeType: + return ErdCode.TURBO_COOL_STATUS + + @property + def turbo_mode(self) -> str: + return OP_MODE_TURBO_COOL + + @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..8d997a8 --- /dev/null +++ b/custom_components/ge_home/entities/fridge/ge_fridge_ice_control_switch.py @@ -0,0 +1,48 @@ +import logging +from homeassistant.const import EntityCategory +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(), entity_category=EntityCategory.CONFIG) + 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/fridge/ge_kcup_switch.py b/custom_components/ge_home/entities/fridge/ge_kcup_switch.py new file mode 100644 index 0000000..13c453c --- /dev/null +++ b/custom_components/ge_home/entities/fridge/ge_kcup_switch.py @@ -0,0 +1,69 @@ +import logging +from propcache.api import cached_property + +from homeassistant.components.switch import SwitchEntity, SwitchDeviceClass +from gehomesdk import ErdCode + +from ...devices import ApplianceApi +from ..common import GeEntity + +_LOGGER = logging.getLogger(__name__) + +# Define the ON and OFF temperature values as constants +K_CUP_ON_TEMP = 190 +K_CUP_OFF_TEMP = 0 + +class GeKCupSwitch(GeEntity, SwitchEntity): + """A switch to control the K-Cup hot water feature.""" + + def __init__(self, api: ApplianceApi): + # Pass the api instance to the base class + super().__init__(api) + + @cached_property + def unique_id(self) -> str: + # Create a unique ID for this entity + return f"{self.api.serial_or_mac}_kcup_hot_water" + + @cached_property + def name(self) -> str | None: + # Set the friendly name to match other switches using the device's unique ID + return f"{self.api.serial_or_mac} K-Cup Hot Water" + + @property + def icon(self) -> str | None: # type: ignore + # Set the icon based on the switch's state + return "mdi:coffee-maker" if self.is_on else "mdi:coffee-maker-off-outline" + + @property + def available(self) -> bool: # type: ignore + return super().available + + @cached_property + def device_class(self) -> SwitchDeviceClass | None: + return None + + @property + def is_on(self) -> bool: # type: ignore + """Return true if the hot water is set to a non-zero temperature.""" + try: + # The switch is "on" if the target temperature is not the "off" value + current_set_temp = self.api.try_get_erd_value(ErdCode.HOT_WATER_SET_TEMP) + return current_set_temp != K_CUP_OFF_TEMP + except Exception as e: + _LOGGER.warning(f"Could not get K-Cup status for {self.unique_id}: {e}") + return False + + async def async_turn_on(self, **kwargs): + """Turn the K-Cup heater on by setting the target temperature.""" + _LOGGER.debug(f"Turning on K-Cup heater for {self.unique_id}") + await self.api.appliance.async_set_erd_value( + ErdCode.HOT_WATER_SET_TEMP, K_CUP_ON_TEMP + ) + + async def async_turn_off(self, **kwargs): + """Turn the K-Cup heater off by setting the target temperature to zero.""" + _LOGGER.debug(f"Turning off K-Cup heater for {self.unique_id}") + await self.api.appliance.async_set_erd_value( + ErdCode.HOT_WATER_SET_TEMP, K_CUP_OFF_TEMP + ) \ No newline at end of file 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..660aacf --- /dev/null +++ b/custom_components/ge_home/entities/hood/ge_hood_fan_speed.py @@ -0,0 +1,59 @@ +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, control_erd_code: Optional[ErdCodeType] = None): + + # old-style + self._availability: ErdHoodFanSpeedAvailability | None = api.try_get_erd_value(ErdCode.HOOD_FAN_SPEED_AVAILABILITY) + + # new-style + if self._availability is None: + fs: int | None = api.try_get_erd_value(ErdCode.HOOD_AVAILABLE_FAN_SPEEDS) + if fs is not None: + self._availability = ErdHoodFanSpeedAvailability.from_count(fs) + + # default + if self._availability is None: + self._availability = ErdHoodFanSpeedAvailability(off_available=True) + + super().__init__(api, erd_code, HoodFanSpeedOptionsConverter(self._availability), control_erd_code=control_erd_code) 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..1447d9c --- /dev/null +++ b/custom_components/ge_home/entities/hood/ge_hood_light_level.py @@ -0,0 +1,87 @@ +import logging +from typing import List, Any, Optional + +from gehomesdk import ErdCodeType, ErdHoodLightLevelAvailability, ErdHoodLightLevel, ErdHoodLightLevelNew, 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.med_available: + self.excluded_levels.append(ErdHoodLightLevel.MED) + 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 HoodLightLevelNewOptionsConverter(OptionsConverter): + def __init__(self, availability: ErdHoodLightLevelAvailability): + super().__init__() + self.availability = availability + self.excluded_levels = [] + if not availability.off_available: + self.excluded_levels.append(ErdHoodLightLevelNew.OFF) + if not availability.dim_available: + self.excluded_levels.append(ErdHoodLightLevelNew.L1) + if not availability.med_available: + self.excluded_levels.append(ErdHoodLightLevelNew.L2) + if not availability.high_available: + self.excluded_levels.append(ErdHoodLightLevelNew.L3) + + @property + def options(self) -> List[str]: + return [i.stringify() for i in ErdHoodLightLevelNew if i not in self.excluded_levels] + def from_option_string(self, value: str) -> Any: + try: + return ErdHoodLightLevelNew[value.upper()] + except: + _LOGGER.warning(f"Could not set hood light level to {value.upper()}") + return ErdHoodLightLevelNew.OFF + def to_option_string(self, value: ErdHoodLightLevelNew) -> Optional[str]: + try: + if value is not None: + return value.stringify() + except: + pass + return ErdHoodLightLevelNew.OFF.stringify() + + +class GeHoodLightLevelSelect(GeErdSelect): + def __init__(self, api: ApplianceApi, erd_code: ErdCodeType, control_erd_code: Optional[ErdCodeType] = None): + self._availability, converter = self._detect_availability(api) + super().__init__(api, erd_code, converter, control_erd_code=control_erd_code) + + def _detect_availability(self, api: ApplianceApi): + if (a := api.try_get_erd_value(ErdCode.HOOD_LIGHT_LEVEL_AVAILABILITY)) is not None: + return a, HoodLightLevelOptionsConverter(a) + + if (ll := api.try_get_erd_value(ErdCode.HOOD_AVAILABLE_LIGHT_LEVELS)) is not None: + a = ErdHoodLightLevelAvailability.from_count(ll) + return a, HoodLightLevelNewOptionsConverter(a) + + a = ErdHoodLightLevelAvailability(off_available=True) + return a, HoodLightLevelOptionsConverter(a) \ No newline at end of file diff --git a/custom_components/ge_home/entities/laundry/__init__.py b/custom_components/ge_home/entities/laundry/__init__.py new file mode 100644 index 0000000..9e91857 --- /dev/null +++ b/custom_components/ge_home/entities/laundry/__init__.py @@ -0,0 +1,2 @@ +from .ge_washer_cycle_button import GeWasherCycleButton +from .ge_dryer_cycle_button import GeDryerCycleButton \ No newline at end of file diff --git a/custom_components/ge_home/entities/laundry/ge_dryer_cycle_button.py b/custom_components/ge_home/entities/laundry/ge_dryer_cycle_button.py new file mode 100644 index 0000000..3afb24d --- /dev/null +++ b/custom_components/ge_home/entities/laundry/ge_dryer_cycle_button.py @@ -0,0 +1,45 @@ +import logging +from propcache.api import cached_property +from datetime import timedelta + +from gehomesdk import ErdCode +from ..common import GeErdButton + +_LOGGER = logging.getLogger(__name__) + +class GeDryerCycleButton(GeErdButton): + """A button to start a dryer cycle.""" + + def __init__(self, api): + super().__init__(api, ErdCode.LAUNDRY_MACHINE_STATE) + + @cached_property + def unique_id(self) -> str: + """Return a unique ID for the button.""" + return f"{self.serial_or_mac}_start_cycle_button" + + @cached_property + def name(self) -> str: + """Return the name of the button.""" + return f"{self.serial_or_mac} Start Cycle" + + @property + def icon(self): + """Return the icon.""" + return "mdi:play-circle" + + @property + def available(self) -> bool: + """The button is only available if remote start is enabled on the appliance.""" + try: + return self.appliance.get_erd_value(ErdCode.LAUNDRY_REMOTE_STATUS) + except: + return False + + async def async_press(self) -> None: + """Send the start command by setting the delay time to zero.""" + _LOGGER.debug(f"Sending START command to {self.unique_id}") + await self.appliance.async_set_erd_value( + ErdCode.LAUNDRY_REMOTE_DELAY_CONTROL, + timedelta(seconds=0) + ) \ No newline at end of file diff --git a/custom_components/ge_home/entities/laundry/ge_washer_cycle_button.py b/custom_components/ge_home/entities/laundry/ge_washer_cycle_button.py new file mode 100644 index 0000000..83222e9 --- /dev/null +++ b/custom_components/ge_home/entities/laundry/ge_washer_cycle_button.py @@ -0,0 +1,45 @@ +import logging +from propcache.api import cached_property +from datetime import timedelta + +from gehomesdk import ErdCode +from ..common import GeErdButton + +_LOGGER = logging.getLogger(__name__) + +class GeWasherCycleButton(GeErdButton): + """A button to start a washer cycle.""" + + def __init__(self, api): + super().__init__(api, ErdCode.LAUNDRY_MACHINE_STATE) + + @cached_property + def unique_id(self) -> str: + """Return a unique ID for the button.""" + return f"{self.serial_or_mac}_start_cycle_button" + + @cached_property + def name(self) -> str: + """Return the name of the button.""" + return f"{self.serial_or_mac} Start Cycle" + + @property + def icon(self): + """Return the icon.""" + return "mdi:play-circle" + + @property + def available(self) -> bool: + """The button is only available if remote start is enabled on the appliance.""" + try: + return self.appliance.get_erd_value(ErdCode.LAUNDRY_REMOTE_STATUS) + except: + return False + + async def async_press(self) -> None: + """Send the start command by setting the delay time to zero.""" + _LOGGER.debug(f"Sending START command to {self.unique_id}") + await self.appliance.async_set_erd_value( + ErdCode.LAUNDRY_REMOTE_DELAY_CONTROL, + timedelta(seconds=0) + ) \ No newline at end of file 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..6c29ca9 --- /dev/null +++ b/custom_components/ge_home/entities/oven/ge_oven.py @@ -0,0 +1,234 @@ +"""GE Home Sensor Entities - Oven""" +import logging +from propcache.api import cached_property +from typing import Any, Dict, List, Optional, Set + +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature + +from gehomesdk import ( + ErdCode, + ErdMeasurementUnits, + ErdOvenCookMode, + OVEN_COOK_MODE_MAP, + OvenCookSetting +) + +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""" + + 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) + + @cached_property + def unique_id(self) -> str: + return f"{DOMAIN}_{self.serial_or_mac}_{self.oven_select.lower()}" + + @cached_property + def name(self) -> str | None: + 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 icon(self) -> str | None: + return "mdi:stove" + + @property + def supported_features(self): + if self.remote_enabled: + return GE_OVEN_SUPPORT + else: + return SUPPORT_NONE + + @cached_property + def temperature_unit(self): + # measurement_system = self.appliance.get_erd_value(ErdCode.TEMPERATURE_UNIT) + # if measurement_system == ErdMeasurementUnits.METRIC: + # return UnitOfTemperature.CELSIUS + # APIs always return Fahrenheit, hardcode + 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) -> int | None: # type: ignore + #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) -> str | None: # type: ignore + 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 + + @cached_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] | None = 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) -> int | None: # type: ignore + """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 is not None and 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]]: # type: ignore + 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..a1c3bcc --- /dev/null +++ b/custom_components/ge_home/entities/oven/ge_oven_light_level_select.py @@ -0,0 +1,68 @@ +import logging +from typing import List, Any, Optional + +from homeassistant.const import EntityCategory +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: Optional[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: Optional[str] = None): + self._availability: ErdOvenLightLevelAvailability | None = api.try_get_erd_value(ErdCode.LOWER_OVEN_LIGHT_AVAILABILITY) + + #check to see if we have a status + value: ErdOvenLightLevel | None = 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, entity_category=EntityCategory.CONFIG) + + @property + def assumed_state(self) -> bool: # type: ignore + return not self._has_status + + @property + def current_option(self) -> str | None: + if self.assumed_state: + return self._assumed_state.name + + 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: ErdOvenLightLevel = 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..a57eb70 --- /dev/null +++ b/custom_components/ge_home/entities/oven/ge_oven_warming_state_select.py @@ -0,0 +1,57 @@ +import logging +from typing import List, Any, Optional + +from homeassistant.const import EntityCategory +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: Optional[str] = None): + #check to see if we have a status + value: ErdOvenWarmingState | None = 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(), erd_override=erd_override, entity_category=EntityCategory.CONFIG) + + @property + def assumed_state(self) -> bool: # type: ignore + return not self._has_status + + @property + def current_option(self): + if self.assumed_state: + return self._assumed_state.name + + 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: ErdOvenWarmingState = 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..4dec041 --- /dev/null +++ b/custom_components/ge_home/entities/water_filter/filter_position.py @@ -0,0 +1,64 @@ +import logging +from typing import List, Any, Optional + +from homeassistant.const import EntityCategory +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", entity_category=EntityCategory.DIAGNOSTIC) + + @property + def current_option(self) -> str | None: + """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]: # type: ignore + """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..3027f70 --- /dev/null +++ b/custom_components/ge_home/entities/water_heater/ge_water_heater.py @@ -0,0 +1,87 @@ +"""GE Home Sensor Entities - Oven""" +import logging +from propcache.api import cached_property +from typing import List + +from homeassistant.components.water_heater import WaterHeaterEntityFeature +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from gehomesdk import ( + ErdCode, + ErdWaterHeaterMode +) + +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""" + + def __init__(self, api: ApplianceApi): + super().__init__(api) + self._modes_converter = WhHeaterModeConverter(api) + + @property + def heater_type(self) -> str: + return "heater" + + @property + def icon(self) -> str | None: + return "mdi:water-boiler" + + @property + def supported_features(self): + return (WaterHeaterEntityFeature.OPERATION_MODE | WaterHeaterEntityFeature.TARGET_TEMPERATURE) + + @cached_property + def temperature_unit(self): + return UnitOfTemperature.FAHRENHEIT + + @property + def current_temperature(self) -> int | None: # type: ignore + return self.appliance.get_erd_value(ErdCode.WH_HEATER_TEMPERATURE) + + @property + def current_operation(self) -> str | None: # type: ignore + erd_mode = self.appliance.get_erd_value(ErdCode.WH_HEATER_MODE) + return self._modes_converter.to_option_string(erd_mode) + + @cached_property + def operation_list(self) -> List[str]: + return self._modes_converter.options + + @property + def target_temperature(self) -> int | None: # type: ignore + """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..1b1c857 --- /dev/null +++ b/custom_components/ge_home/entities/water_heater/heater_modes.py @@ -0,0 +1,106 @@ +import logging +from typing import List, Any, Optional + +from gehomesdk import ErdWaterHeaterMode, ErdCode, ErdBrand +from ..common import OptionsConverter + +_LOGGER = logging.getLogger(__name__) + +class WhHeaterModeConverter(OptionsConverter): + def __init__(self, api=None): + """Initialize with optional API reference to check brand.""" + self._api = api + self._brand = None + if api: + try: + self._brand = api.try_get_erd_value(ErdCode.BRAND) + except: + pass + + @property + def is_haier(self) -> bool: + """Check if this is a Haier brand water heater.""" + return self._brand in [ErdBrand.HEIER, ErdBrand.HEIER_FPA] + + def _get_mode_name(self, mode: ErdWaterHeaterMode) -> Optional[str]: + """Get the display name for a mode, considering brand.""" + if mode == ErdWaterHeaterMode.UNKNOWN: + return None + + # Haier-specific mode names (High Demand not supported on Haier) + if self.is_haier: + # High Demand mode doesn't exist on Haier water heaters + if mode == ErdWaterHeaterMode.HIGH_DEMAND: + return None + + haier_names = { + ErdWaterHeaterMode.HYBRID: "Auto", + ErdWaterHeaterMode.STANDARD_ELECTRIC: "Electric", + ErdWaterHeaterMode.HEAT_PUMP: "Eco", + ErdWaterHeaterMode.VACATION: "Vacation" + } + return haier_names.get(mode, mode.stringify()) + + # Default GE mode names + return mode.stringify() + + def get_mode_name(self, mode: ErdWaterHeaterMode) -> Optional[str]: + """Public method to get the display name for a mode.""" + return self._get_mode_name(mode) + + def get_available_options(self) -> List[str]: + """Get list of available mode options with brand-specific names.""" + return self.options + + def get_mode_from_name(self, name: str) -> Optional[ErdWaterHeaterMode]: + """Get the mode enum from display name.""" + return self.from_option_string(name) + + def set_appliance(self, appliance): + """Set the appliance reference to check brand.""" + if appliance and not self._brand: + try: + self._brand = appliance.get_erd_value(ErdCode.BRAND) + except: + pass + + @property + def options(self) -> List[str]: + """Get list of available mode options with brand-specific names.""" + options = [] + for mode in ErdWaterHeaterMode: + name = self._get_mode_name(mode) + if name: + options.append(name) + return options + + def from_option_string(self, value: str) -> Any: + """Convert from display string to ErdWaterHeaterMode.""" + # Handle Haier-specific names + if self.is_haier: + haier_mapping = { + "AUTO": ErdWaterHeaterMode.HYBRID, + "ELECTRIC": ErdWaterHeaterMode.STANDARD_ELECTRIC, + "ECO": ErdWaterHeaterMode.HEAT_PUMP, + "VACATION": ErdWaterHeaterMode.VACATION + } + normalized = value.upper() + if normalized in haier_mapping: + return haier_mapping[normalized] + + # Try standard mapping + enum_val = value.upper().replace(" ", "_") + try: + return ErdWaterHeaterMode[enum_val] + except: + _LOGGER.warning(f"Could not convert heater mode from {value}") + return ErdWaterHeaterMode.UNKNOWN + + def to_option_string(self, value: ErdWaterHeaterMode) -> Optional[str]: + """Convert from ErdWaterHeaterMode to display string.""" + try: + if value is not None: + return self._get_mode_name(value) + except: + pass + return self._get_mode_name(ErdWaterHeaterMode.UNKNOWN) 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..015e56a --- /dev/null +++ b/custom_components/ge_home/entities/water_softener/shutoff_position.py @@ -0,0 +1,66 @@ +import logging +from typing import List, Any, Optional + +from homeassistant.const import EntityCategory +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", entity_category=EntityCategory.CONFIG) + + @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]: # type: ignore + """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..d10706d --- /dev/null +++ b/custom_components/ge_home/exceptions.py @@ -0,0 +1,12 @@ +""" 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""" +class HaInvalidOperation(ha_exc.HomeAssistantError): + """Error to indcate that an invalid operation was attempted""" \ 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..fc53f23 --- /dev/null +++ b/custom_components/ge_home/humidifier.py @@ -0,0 +1,45 @@ +"""GE Home Humidifier Entities""" +import logging +from collections.abc import Collection +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 Humidifiers""" + _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: Collection[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 to register') + 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..91314de --- /dev/null +++ b/custom_components/ge_home/light.py @@ -0,0 +1,49 @@ +"""GE Home Select Entities""" +import logging +from collections.abc import Collection +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: Collection[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 to register") + 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..88d643f --- /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>=2026.2.0","magicattr==0.1.6"], + "codeowners": ["@simbaja"], + "version": "2026.2.0" +} diff --git a/custom_components/ge_home/number.py b/custom_components/ge_home/number.py new file mode 100644 index 0000000..4f148da --- /dev/null +++ b/custom_components/ge_home/number.py @@ -0,0 +1,46 @@ +"""GE Home Number Entities""" +import logging +from collections.abc import Collection +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: Collection[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 to register') + 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..ba1e832 --- /dev/null +++ b/custom_components/ge_home/select.py @@ -0,0 +1,49 @@ +"""GE Home Select Entities""" +import logging +from collections.abc import Collection +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: Collection[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 to register") + 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..c5b1160 --- /dev/null +++ b/custom_components/ge_home/sensor.py @@ -0,0 +1,89 @@ +"""GE Home Sensor Entities""" +import logging +import voluptuous as vol +from collections.abc import Collection +from datetime import timedelta +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_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: Collection[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 to register') + 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/switch.py b/custom_components/ge_home/switch.py new file mode 100644 index 0000000..66e606b --- /dev/null +++ b/custom_components/ge_home/switch.py @@ -0,0 +1,58 @@ +"""GE Home Switch Entities""" +import logging +from collections.abc import Collection +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 homeassistant.components.switch import SwitchEntity + +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 Switches.""" + _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: Collection[ApplianceApi]): + """Add new switch entities from the device API.""" + _LOGGER.debug(f'Found {len(apis):d} appliance APIs') + + new_entities = [] + for api in apis: + for entity in api.entities: + # Skip if the entity is already registered + if registry.async_is_registered(entity.entity_id): + continue + + # Check if it's a switch entity we should add + if isinstance(entity, SwitchEntity): + # Special handling for GeErdSwitch: it requires the erd_code to be in the property cache + if isinstance(entity, GeErdSwitch): + if entity.erd_code in api.appliance._property_cache: + new_entities.append(entity) + else: + # For other switche types add them directly + new_entities.append(entity) + + _LOGGER.debug(f'Found {len(new_entities):d} unregistered switches to register') + async_add_entities(new_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)) \ No newline at end of file diff --git a/custom_components/ge_home/translations/en.json b/custom_components/ge_home/translations/en.json new file mode 100644 index 0000000..ca46fc2 --- /dev/null +++ b/custom_components/ge_home/translations/en.json @@ -0,0 +1,30 @@ +{ + "title": "GE Home (SmartHQ)", + "config": { + "step": { + "user": { + "data": { + "username": "Username", + "password": "Password", + "region": "Region" + } + }, + "reauth": { + "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 already configured!", + "reauth_successful": "Re-authentication was successful!" + } + } +} diff --git a/custom_components/ge_home/update_coordinator.py b/custom_components/ge_home/update_coordinator.py new file mode 100644 index 0000000..2f2a908 --- /dev/null +++ b/custom_components/ge_home/update_coordinator.py @@ -0,0 +1,676 @@ +"""Data update coordinator for GE Home (SmartHQ) Appliances""" + +import asyncio +from contextlib import suppress +import random +import logging +import time +from typing import Any, Callable, Dict, Iterable, Optional, Tuple, List + +from homeassistant.components import persistent_notification +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 import device_registry as dr, entity_registry as er +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 gehomesdk import ( + EVENT_APPLIANCE_INITIAL_UPDATE, + EVENT_APPLIANCE_UPDATE_RECEIVED, + EVENT_CONNECTED, + EVENT_DISCONNECTED, + EVENT_GOT_APPLIANCE_LIST, + ErdCodeType, + GeAppliance, + GeWebsocketClient, + ErdApplianceType, + GeClientState +) +from gehomesdk import GeAuthFailedError, GeGeneralServerError, GeNotAuthenticatedError + +from .const import * +from .devices import ApplianceApi, get_appliance_api_type +from .exceptions import HaAuthError, HaCannotConnect + +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._client : GeWebsocketClient | None = None + 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: Dict[str, ApplianceApi] = {} + self._signal_remove_callbacks: List[Callable] = [] + self._got_roster = False + self._init_done = False + self._all_initial_updates_received = asyncio.Event() + + self._updater_task: asyncio.Task | None = None + self._reconnect_task: asyncio.Task | None = None + self._last_persistent_log: float = 0.0 + self._retry_count: int = 0 + self._last_ha_refresh: float = 0.0 + + self._reset_sync_state() + + #region Public Properties + + @property + def appliances(self) -> Iterable[GeAppliance]: + if self._client is None: + return [] + + return self._client.appliances.values() + + @property + def appliance_apis(self) -> Dict[str, ApplianceApi]: + return self._appliance_apis + + @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]) + + @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 is not None and self._client.connected + + @property + def available(self) -> bool: + """ + Indicates whether the coordinator is available + """ + return self._client is not None and self._client.available + + #endregion + + #region Public Methods + + def add_signal_remove_callback(self, cb: Callable): + self._signal_remove_callbacks.append(cb) + + async def async_setup(self): + """Setup a new coordinator""" + _LOGGER.debug("Setting up the 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 as exc: + raise HaCannotConnect("Unknown connection failure") from exc + + return True + + async def async_reset(self) -> bool: + """Resets the coordinator.""" + _LOGGER.debug("Resetting the coordinator") + entry = self._config_entry + + # stop the client + await self._async_stop_client() + + # remove all the callbacks for this coordinator + for c in self._signal_remove_callbacks: + c() + self._signal_remove_callbacks.clear() + + # clear the appliances (moved from _reset_sync_state to ensure proper cleanup on unload) + self._appliance_apis.clear() + + # cancel the notification + try: + persistent_notification.async_dismiss(self.hass, CONNECTION_NOTIFICATION_ID) + except Exception: + pass + + # unload + unload_ok = await self.hass.config_entries.async_unload_platforms( + self._config_entry, PLATFORMS + ) + return unload_ok + + @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") + + #stop the client and existing background tasks + self.hass.loop.create_task(self._async_stop_client()) + + #endregion + + #region Internal Methods + + #region Initialization/Reset/Shutdown + + 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 + + async def _async_start_client(self) -> None: + """ + Tear down old client if present, reset state, and create & start a fresh client. + """ + + # Teardown old client if present + await self._async_stop_client() + + # Create new client and start it + try: + self._client = self._create_ge_client(event_loop=self.hass.loop) + session = async_get_clientsession(self.hass) + await self._client.async_get_credentials(session) + except Exception as err: + _LOGGER.error(f"could not start the client: {err}") + self._client = None + raise + + # Start the client run loop + self.hass.loop.create_task(self._client.async_run_client()) + _LOGGER.debug("Scheduled the client for execution.") + + async def _async_stop_client(self): + """ Teardown the client if it exists """ + if self._client: + try: + self._client.clear_event_handlers() + await self._client.disconnect() + except Exception as err: + _LOGGER.warning("Error disconnecting client: %s", err) + finally: + self._client = None + + # Reset asynchronous and synchronous states + await self._async_reset_state() + self._reset_sync_state() + + def _reset_sync_state(self): + """ Reset synchronous state """ + + # clear the appliances + # self._appliance_apis.clear() # MOVED to async_reset to allow for persistence across reconnections + + # reset the initialization + self._all_initial_updates_received.clear() + + # 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 + + async def _async_reset_state(self): + """ Reset asynchronous state """ + + await self._stop_periodic_updates() + await self._stop_reconnect_worker() + + #endregion + + #region Reconnection Lifecycle + + async def _ensure_client_running(self) -> None: + if self._client is None or self._client.state == GeClientState.DISCONNECTED: + _LOGGER.debug("Client missing or disconnected, starting new client") + await self._async_start_client() + + async def _start_reconnect_worker(self) -> None: + if self._reconnect_task and not self._reconnect_task.done(): + return + self._reconnect_task = self.hass.loop.create_task(self._reconnect_worker()) + + async def _stop_reconnect_worker(self) -> None: + self._retry_count = 0 + self._last_persistent_log = 0.0 + if self._reconnect_task: + self._reconnect_task.cancel() + with suppress(asyncio.CancelledError): + await self._reconnect_task + self._reconnect_task = None + try: + persistent_notification.async_dismiss(self.hass, CONNECTION_NOTIFICATION_ID) + except Exception: + pass + + async def _reconnect_worker(self) -> None: + _LOGGER.debug("Reconnect worker started") + try: + while True: + if self._client and self._client.state != GeClientState.DISCONNECTED: + _LOGGER.debug("Client no longer disconnected, exiting worker") + return + + self._retry_count += 1 + sleep_time = self._get_retry_delay() + + _LOGGER.info(f"Retrying in {sleep_time:.1f}s (attempt {self._retry_count})") + await asyncio.sleep(sleep_time) + + if self._client and self._client.state != GeClientState.DISCONNECTED: + _LOGGER.debug("Client became healthy before retry, exiting") + return + + try: + await self._async_start_client() + except (GeNotAuthenticatedError, GeAuthFailedError): + self._show_persistent_notification("Authentication failure: please re-authenticate the GE Home integration.") + return + except Exception as err: + _LOGGER.warning(f"Reconnect attempt failed: {err}") + + if self._client and self._client.state != GeClientState.DISCONNECTED: + return + + if self._retry_count >= NOTIFY_AFTER_RETRIES: + self._show_notification_once_per_interval( + title="GE Home: connection issues", + message=f"Unable to connect after {self._retry_count} attempts. Will continue retrying automatically.", + interval=PERSISTENT_RETRY_LOG_INTERVAL, + ) + await self._throttled_refresh_ha_state() + except asyncio.CancelledError: + _LOGGER.debug("Reconnect worker cancelled, ignoring.") + finally: + if self._reconnect_task and self._reconnect_task.done(): + self._reconnect_task = None + + def _get_retry_delay(self) -> float: + delay = float(min(MIN_RETRY_DELAY * (2 ** self._retry_count), MAX_RETRY_DELAY)) + jitter = delay * RECONNECT_JITTER * (random.random() * 2 - 1) + return delay + jitter + + #endregion + + #region Persistent Notifications + + def _show_persistent_notification(self, message: str, title: str = "GE Home Connection"): + try: + persistent_notification.async_create(self.hass, message, title, notification_id=CONNECTION_NOTIFICATION_ID) + except Exception: + _LOGGER.exception("Failed to create persistent notification") + + def _show_notification_once_per_interval(self, title: str, message: str, interval: int = 300): + now = time.time() + if now - self._last_persistent_log > interval: + self._last_persistent_log = now + self._show_persistent_notification(message, title) + + #endregion + + #region Client Event Handlers + + 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, update_data = data + + self._dump_appliance(appliance, update_data) + + if not self._is_appliance_valid(appliance): + _LOGGER.debug(f"on_device_update: skipping invalid appliance {appliance.mac_addr}") + return + + 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 _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 + + try: + await asyncio.wait_for(self._all_initial_updates_received.wait(), timeout=INITIAL_UPDATE_TIMEOUT) + except asyncio.TimeoutError: + _LOGGER.warning("Timeout waiting for initial appliance updates") + finally: + # Remove stale devices/entities after everything is ready + await self._async_remove_stale_devices() + + # Trigger all-ready signal + await self._async_maybe_trigger_all_ready(True) + + async def _on_device_initial_update(self, appliance: GeAppliance): + """When an appliance first becomes ready, let the system know and schedule periodic updates.""" + + self._dump_appliance(appliance) + + if not self._is_appliance_valid(appliance): + _LOGGER.debug(f"on_device_initial_update: skipping invalid appliance {appliance.mac_addr}") + return + + _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() + await self._start_periodic_updates() + + async def _on_disconnect(self, _): + """Handle disconnection.""" + _LOGGER.debug(f"Client has been disconnected, starting reconnection attempts.") + self.last_update_success = False + await self._start_reconnect_worker() + + async def _on_connect(self, _): + """Set state upon connection.""" + self.last_update_success = True + await self._stop_reconnect_worker() + + #endregion + + #region Appliance Management + + def _is_appliance_valid(self, appliance: GeAppliance) -> bool: + return appliance.appliance_type is not None + + def _get_appliance_api(self, appliance: GeAppliance) -> ApplianceApi: + if appliance is None: + return None + + self._dump_appliance(appliance) + api_type = get_appliance_api_type(appliance.appliance_type or ErdApplianceType.UNKNOWN) + return api_type(self, appliance) + + def _maybe_add_appliance_api(self, appliance: GeAppliance) -> None: + 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: + _LOGGER.debug(f"Already have appliance {mac_addr} ({appliance.appliance_type}), switching reference.") + # if we already have the API, switch out its appliance reference for this one + api = self.appliance_apis[mac_addr] + api.appliance = appliance + api.build_entities_list() + + async def _async_maybe_trigger_all_ready(self, force: bool = False) -> None: + """See if we're all ready to go, and if so, let the games begin.""" + if self._init_done: + # Been here, done this + _LOGGER.debug("Already initialized, cannot trigger ready.") + return + + if self._client is None: + _LOGGER.warning("Client is already deallocated, cannot trigger ready.") + return + + if force or (self._got_roster and self.all_appliances_updated): + _LOGGER.debug("Ready to go, sending ready signal!") + self._init_done = True + self._all_initial_updates_received.set() + + await self._client.async_event(EVENT_ALL_APPLIANCES_READY, None) + async_dispatcher_send( + self.hass, + self.signal_ready, + list(self.appliance_apis.values())) + + async def _async_remove_stale_devices(self): + """Remove devices/entities from HA that no longer exist in the cloud.""" + if self._client is None: + return + + # Device and entity registries + device_registry = dr.async_get(self.hass) + entity_registry = er.async_get(self.hass) + + # MAC addresses of all currently valid appliances + # we need to look at the cloud list, not our internal list, as we may have stale entries in our internal list + if self._client and self._client.appliances: + valid_macs = set(self._client.appliances.keys()) + else: + valid_macs = set() + + # Remove stale appliance APIs from our internal list + for mac in list(self._appliance_apis.keys()): + if mac not in valid_macs: + _LOGGER.info(f"Removing stale appliance API {mac}") + self._appliance_apis.pop(mac) + + # Update current macs for HA registry cleanup + current_macs = valid_macs + + # Loop through all devices for this config entry + for device_entry in list(device_registry.devices.values()): + # Skip devices not associated with this config entry + if self._config_entry.entry_id not in device_entry.config_entries: + continue + + # Extract mac_addresses (assumes identifiers contain ("ge_home", mac)) + device_mac = None + for ident in device_entry.identifiers: + if ident[0] == DOMAIN: # DOMAIN = "ge_home" + device_mac = ident[1] + break + + if device_mac and device_mac not in current_macs: + _LOGGER.info(f"Removing stale device {device_entry.name} ({device_mac}) from HA registry") + + # Remove all entities linked to this device + for entity_entry in list(entity_registry.entities.values()): + if entity_entry.device_id == device_entry.id: + entity_registry.async_remove(entity_entry.entity_id) + + # Remove the device itself + device_registry.async_remove_device(device_entry.id) + + #endregion + + #region Background Updates + + async def _start_periodic_updates(self): + + if self._updater_task is not None and not self._updater_task.done(): + _LOGGER.debug("Polling already started, ignoring scheduling request.") + return + + self._updater_task = self.hass.loop.create_task(self._request_periodic_updates()) + _LOGGER.debug("Scheduled background updater for execution.") + + async def _stop_periodic_updates(self) -> None: + if self._updater_task: + self._updater_task.cancel() + with suppress(asyncio.CancelledError): + await self._updater_task + self._updater_task = None + + async def _request_periodic_updates(self): + """Periodic update loop.""" + + _LOGGER.debug("Start requesting periodic updates.") + + try: + while self.connected: + await asyncio.sleep(STATE_UPDATE_INTERVAL) + + if (self._client is None or not self.connected or not self._client.available): + _LOGGER.debug( + f"Connection issue, cannot get update (" + f"client: { self._client is None }," + f"connected: { self.connected }," + f"available: { self.available }" + ) + continue + + for api in self.appliance_apis.values(): + try: + if api.appliance is None: + _LOGGER.debug(f"Appliance {api} is not valid, skipping update.") + continue + + _LOGGER.debug(f"Requesting update for {api.appliance.mac_addr}") + await api.appliance.async_request_update() + except Exception as err: + _LOGGER.debug(f"Poll update failed for [{api.appliance.mac_addr}]: {err}") + + except asyncio.CancelledError: + # Normal exit when shutting down + pass + + _LOGGER.debug("Stopped requesting periodic updates.") + + #endregion + + #region State Updates + + async def _refresh_ha_state(self): + """ Performs a full refresh of all appliances """ + 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]): + """ Performs a refresh of the state for a list of entities """ + + 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}), state: {entity.state}") + entity.async_write_ha_state() + except: + _LOGGER.warning(f"Could not refresh state for {entity} ({entity.unique_id}, {entity.entity_id}", exc_info=True) + + async def _throttled_refresh_ha_state(self): + now = time.time() + if now - self._last_ha_refresh > HA_REFRESH_INTERVAL: + try: + await self._refresh_ha_state() + except Exception: + _LOGGER.debug("Error refreshing HA state during reconnect", exc_info=True) + finally: + self._last_ha_refresh = now + + #endregion + + #region Debugging + + def _dump_appliance(self, appliance: GeAppliance, update_data: Optional[Dict[ErdCodeType, Any]] = None) -> None: + if not _LOGGER.isEnabledFor(logging.DEBUG): + return + + import pprint + try: + _LOGGER.debug(f"--- COMPREHENSIVE DUMP FOR APPLIANCE: {appliance.mac_addr} ---") + appliance_data = {} + # dir() gets all attrs, including properties and methods + for attr_name in dir(appliance): + # skip "magic" methods and "private" attributes to reduce noise + if attr_name.startswith('_'): + continue + try: + value = getattr(appliance, attr_name) + # for now skip methods - we only want data + if callable(value): + continue + appliance_data[attr_name] = value + except Exception: + # some props might fail if called out of context + appliance_data[attr_name] = "Error: Could not read attribute" + + # add the internal property cache (i.e. current values) + appliance_data["property_cache"] = appliance._property_cache + + # add the update data if available + if update_data is not None: + appliance_data["update_data"] = update_data + + _LOGGER.debug(pprint.pformat(appliance_data)) + _LOGGER.debug("--- END OF COMPREHENSIVE DUMP ---") + except Exception as e: + _LOGGER.error(f"Could not dump appliance {appliance}: {e}") + + #endregion + + #endregion + diff --git a/custom_components/ge_home/water_heater.py b/custom_components/ge_home/water_heater.py new file mode 100644 index 0000000..e1b1cfa --- /dev/null +++ b/custom_components/ge_home/water_heater.py @@ -0,0 +1,45 @@ +"""GE Home Sensor Entities""" +import logging +from collections.abc import Collection +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 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: Collection[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 to register') + 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/translations/en.json b/ge_kitchen/translations/en.json deleted file mode 100644 index 142a25e..0000000 --- a/ge_kitchen/translations/en.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "title": "GE Kitchen", - "config": { - "step": { - "init": { - "data": { - "username": "Username", - "password": "Password" - } - }, - "user": { - "data": { - "username": "Username", - "password": "Password" - } - } - }, - "error": { - "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication", - "unknown": "Unexpected error" - }, - "abort": { - "already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]" - } - } -} \ No newline at end of file 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..02eff7e --- /dev/null +++ b/hacs.json @@ -0,0 +1,6 @@ +{ + "name": "GE Home (SmartHQ)", + "homeassistant": "2025.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..31540f6 --- /dev/null +++ b/info.md @@ -0,0 +1,324 @@ +# 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.11.0'.split('.') | map('int') %} +- changed name of some SAC/WAC entities to have a AC prefix +{% endif %} + +{% 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') < '2026.2.0'.split('.') | map('int') %} +- Changed mode names for Haier water heaters [#442] +- Made LAUNDRY_MACHINE_STATE diagnostic on all appliances [#447] +{% endif %} + +{% if version_installed.split('.') | map('int') < '2025.11.0'.split('.') | map('int') %} +- Refactored code internally to improve reliability +- Cleaned up initialization and config flow +{% endif %} + +{% if version_installed.split('.') | map('int') < '2025.7.0'.split('.') | map('int') %} +- Silenced string prep warning [#386] (@derekcentrico) +{% endif %} + +{% 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') < '2026.2.0'.split('.') | map('int') %} +- Added DRY mode to HVAC options and mappings [#441] +- Added GeWasherCycleButton to WasherDryerApi [#462] +- Added DishDrawer User Setting wifi_enabled (read only) [#463] +{% endif %} + +{% if version_installed.split('.') | map('int') < '2025.12.0'.split('.') | map('int') %} +- Changed time-related entities to be durations instead of text [#312] +{% endif %} + +{% if version_installed.split('.') | map('int') < '2025.11.0'.split('.') | map('int') %} +- Added heat mode for Window ACs +- Added support for Advantium +- Brand inference and stale device cleanup +- Added support for new hoods that require state/control ERDs +- Added entity categorization +- Added dishwasher remote commands +{% endif %} + +{% if version_installed.split('.') | map('int') < '2025.7.0'.split('.') | map('int') %} +- Enabled Washer/Dryer remote start [#369] (@derekcentrico) +- Enabled K-cup refrigerator functionality [#101] (@derekcentrico) +{% endif %} + +{% 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') < '2026.2.0'.split('.') | map('int') %} +- Cooktop Sensor fixes [#440, #454] +- Persist ApplianceApis on reconnect to prevent duplicate entities [#464] +{% endif %} + +{% if version_installed.split('.') | map('int') < '2025.11.0'.split('.') | map('int') %} +- Climate heat mode setting [#433, #435] +{% endif %} + +{% if version_installed.split('.') | map('int') < '2025.5.0'.split('.') | map('int') %} +- Fixed temperature unit for ovens [#248, #328, #344] +- Water heater mode setting [#107] +{% endif %} + +{% 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') %} +- Error checking socket status [#304] +- Error with setup [#301] +- Logger deprecations +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.13'.split('.') | map('int') %} +- Deprecations [#290] [#297] +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.12'.split('.') | map('int') %} +- Deprecations [#271] +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.13'.split('.') | map('int') %} +- Deprecations [#290] [#297] +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.12'.split('.') | map('int') %} +- Deprecations [#271] +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.11'.split('.') | map('int') %} +- Fixed convertable drawer issue (#243) +- Updated app types to include electric cooktops (#252) +- Updated clientsession to remove deprecation (#253) +- Fixed error strings +- Updated climate support for new flags introduced in 2024.2.0 +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.10'.split('.') | map('int') %} +- Removed additional deprecated constants (#229) +- Fixed issue with climate entities (#228) +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.9'.split('.') | map('int') %} +- Additional auth stability improvements (#215, #211) +- Removed deprecated constants (#218) +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.8'.split('.') | map('int') %} +- Fixed issue with oven lights (#174) +- Fixed issues with dual dishwasher (#161) +- Fixed disconnection issue (#169) +{% endif %} + +{% if version_installed.split('.') | map('int') < '0.6.7'.split('.') | map('int') %} +- 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 %}