diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..375e328 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,218 @@ + +# GE Home Appliances (SmartHQ) Changelog + +## 2026.6.0 + +- Feature: Added hood fan and light entities [#507] +- Feature: Added cooktop-specific entities (sensors and controls) +- Feature: Added kitchen timer to cooktop as number entities (in minutes) +- Feature: Added gas cooktop to known device mapping +- Feature: Added quiet/turbo mode for AC-capable appliances [#397] +- Feature: Added potential support for dishwasher delay start on some models [#434] +- Feature: Added cross-appliance resource usage sensors to all supported appliances (instantaneous power, cumulative energy, hot/cold water usage, gas usage) where reported by the appliance [#335,#492] +- Change: Improved login error messages now specifically indicate when MFA or Terms of Service acceptance is required +- Change: Refactored common cooktop logic to apply to both cooktop and oven devices +- Change: Tightened typing for binary sensor and sensor device/state classes +- Change: Removed AC-specific demand response sensors from WAC/BIAC (superseded by cross-appliance resource sensors) +- Bugfix: Fixed dehumidifier sensors incorrectly typed (should be binary sensors) +- Bugfix: Fixed typing issue in oven target_temperature +- Bugfix: Avoid returning None for oven target_temperature [#457] +- Bugfix: Allow eco mode access for non-heating split ACs [#474] +- Bugfix: Fix oven off mode to use HA's STATE_OFF constant [#485] +- Bugfix: Restore appliance availability on state update [#495] +- Bugfix: Ignore non-printable serial numbers and fall back to MAC as unique device identifier [#502] + +## 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] +- Feature: Added native fan and light entities for range hoods +- 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..6db16c0 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,98 @@ -# 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) + +## Range Hood HomeKit Controls + +Range hoods expose native `fan` and `light` entities when the appliance reports hood fan speed and light level controls. These entities are intended to work better with Home Assistant dashboards, voice assistants, and the HomeKit Bridge than the lower-level select entities. Hood fans expose low, medium, and high as regular fan speeds; boost is exposed as a preset mode when available. + +The existing hood fan speed and light level select entities remain available for compatibility, but are disabled by default for new entity registry entries. Re-enable them from the entity settings if you use them in dashboards, scripts, or automations. For the best HomeKit experience, expose the `fan` and `light` hood entities to the HomeKit Bridge and exclude any helper switches or select entities that were previously used to represent individual hood modes. + +## 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!) + - If terms acceptance or multi-factor authentication (MFA) is required, a specific error message will guide you to complete those steps in the SmartHQ app before retrying. +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..4495e3c --- /dev/null +++ b/custom_components/ge_home/config_flow.py @@ -0,0 +1,172 @@ +"""Config flow for GE Home integration.""" + +import logging +from typing import Dict, Optional + +import aiohttp +import asyncio +import async_timeout + +from gehomesdk import ( + GeAuthFailedError, + GeAuthMfaRequiredError, + GeAuthTermsRequiredError, + 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, HaMfaRequired, HaTermsRequired + +_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 GeAuthMfaRequiredError: + _LOGGER.warning(f"MFA required for user {username} in region {region}") + raise HaMfaRequired("MFA required") + except GeAuthTermsRequiredError: + _LOGGER.warning(f"Terms acceptance required for user {username} in region {region}") + raise HaTermsRequired("Terms acceptance required") + 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 HaMfaRequired: + return None, {"base": "mfa_required"} + except HaTermsRequired: + return None, {"base": "terms_required"} + 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..dd92b3f --- /dev/null +++ b/custom_components/ge_home/devices/__init__.py @@ -0,0 +1,67 @@ +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.GAS_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..a2ea8d4 --- /dev/null +++ b/custom_components/ge_home/devices/advantium.py @@ -0,0 +1,56 @@ +import logging +from typing import List + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +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=BinarySensorDeviceClass.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..fc61df5 --- /dev/null +++ b/custom_components/ge_home/devices/base.py @@ -0,0 +1,277 @@ +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) and + self.serial_number.isprintable()): + return self.serial_number + if self.serial_number and not self.serial_number.isprintable(): + _LOGGER.warning( + "Serial number for %s contains non-printable characters " + "(possible certificate data on ERD 0x0002); falling back to MAC address.", + self.mac_addr, + ) + return self.mac_addr + + @cached_property + def brand_id(self) -> ErdBrand: + """Resolve the appliance brand, inferring from the model number when + the BRAND ERD is absent or unknown.""" + 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 b + + @cached_property + def brand(self) -> str: + return ERD_BRAND_NAME_MAP.get(self.brand_id, 'GE') + + @property + def is_fisher_paykel(self) -> bool: + """Whether this appliance is Fisher & Paykel branded. + + F&P appliances differ from GE here: metric-market F&P models report + their raw ERD temperatures in Celsius, whereas GE appliances always + report Fahrenheit (and may report a METRIC unit while still sending + Fahrenheit). Used to decide whether the device's TEMPERATURE_UNIT can + be trusted for temperature reporting.""" + return self.brand_id in (ErdBrand.FISHER_PAYKEL, ErdBrand.HEIER_FPA) + + @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 homeassistant.components.sensor import SensorDeviceClass, SensorStateClass + from ..entities import GeErdSensor, GeErdSwitch, GeErdPropertySensor + entities = [ + GeErdSensor(self, ErdCode.CLOCK_TIME, entity_category=EntityCategory.DIAGNOSTIC), + GeErdSwitch(self, ErdCode.SABBATH_MODE), + ] + + # Resource monitoring sensors - available on supported appliances + # build_entities_list filters these against known_properties automatically + entities += [ + GeErdSensor(self, ErdCode.RESOURCE_DEMAND_RESPONSE_STATE, entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.RESOURCE_CUMULATIVE_ENERGY, + uom_override="Wh", + device_class_override=SensorDeviceClass.ENERGY, + state_class_override=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.RESOURCE_CUMULATIVE_COLD_WATER_LITERS, + uom_override="L", + device_class_override=SensorDeviceClass.WATER, + state_class_override=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.RESOURCE_CUMULATIVE_HOT_WATER_LITERS, + uom_override="L", + device_class_override=SensorDeviceClass.WATER, + state_class_override=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.RESOURCE_CUMULATIVE_GAS_CUBIC_FEET, + uom_override="ft³", + device_class_override=SensorDeviceClass.GAS, + state_class_override=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC), + ] + + entities.append( + GeErdPropertySensor( + self, + ErdCode.RESOURCE_DSM_POWER_USAGE, + "instantaneous_power_w", + uom_override="W", + device_class_override=SensorDeviceClass.POWER, + state_class_override=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ) + ) + + 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, brand_or_idx in BRAND_SPECIAL_PREFIXES.items(): + if m.startswith(prefix): + if isinstance(brand_or_idx, ErdBrand): + _LOGGER.debug(f"Model '{m}': inferred brand '{brand_or_idx.name}' from prefix '{prefix}'") + return brand_or_idx + + idx = brand_or_idx + 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 diff --git a/custom_components/ge_home/devices/biac.py b/custom_components/ge_home/devices/biac.py new file mode 100644 index 0000000..507cfe5 --- /dev/null +++ b/custom_components/ge_home/devices/biac.py @@ -0,0 +1,51 @@ +import logging +from typing import List + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +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, GeErdSelect, ErdOnOffBoolConverter, GeErdBinarySensor, TurboQuietModeOptionsConverter + + +_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=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC), + ] + + if self.has_erd_code(ErdCode.AC_TURBO_QUIET_MODE): + available_modes = self.try_get_erd_value(ErdCode.AC_AVAILABLE_TURBO_QUIET_MODES) + sac_entities.append( + GeErdSelect( + self, + ErdCode.AC_TURBO_QUIET_STATUS, + TurboQuietModeOptionsConverter( + has_turbo=available_modes.has_turbo if available_modes else True, + has_quiet=available_modes.has_quiet if available_modes else True, + ), + control_erd_code=ErdCode.AC_TURBO_QUIET_MODE, + icon_override="mdi:fan-speed-2", + entity_category=EntityCategory.CONFIG, + ) + ) + + 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..6219879 --- /dev/null +++ b/custom_components/ge_home/devices/coffee_maker.py @@ -0,0 +1,68 @@ +import logging +from typing import List + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +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=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC), + GeCcmPotNotPresentBinarySensor(self, ErdCode.CCM_POT_PRESENT, device_class_override=BinarySensorDeviceClass.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..a0b54e3 --- /dev/null +++ b/custom_components/ge_home/devices/const.py @@ -0,0 +1,23 @@ +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 | ErdBrand] = { + "UVC": ErdBrand.GE, # GE vent hoods + "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..a908a36 --- /dev/null +++ b/custom_components/ge_home/devices/cooktop.py @@ -0,0 +1,152 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.helpers.entity import Entity +from gehomesdk import ( + CooktopStatus, + ErdCode, + ErdApplianceType, + ErdCooktopConfig, + ErdDataType, +) +from ..entities import ( + GeCooktopStatusBinarySensor, + GeErdPropertyBinarySensor, + GeErdPropertySensor, + GeErdBinarySensor, + GeErdButton, + GeErdSensor, + GeErdTimerSensor, + GeErdTimerNumber +) +from .base import ApplianceApi + +_LOGGER = logging.getLogger(__name__) + + +def build_cooktop_entities(api: ApplianceApi) -> List[Entity]: + """Create cooktop entities if this appliance reports a cooktop.""" + + cooktop_config = ErdCooktopConfig.NONE + if api.has_erd_code(ErdCode.COOKTOP_CONFIG): + cooktop_config: ErdCooktopConfig = api.appliance.get_erd_value( + ErdCode.COOKTOP_CONFIG + ) + + if cooktop_config != ErdCooktopConfig.PRESENT: + return [] + + # attempt to get cooktop status, preferring extended data when present + cooktop_status_erd = ErdCode.COOKTOP_STATUS_EXT + cooktop_status: CooktopStatus | None = api.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 = api.try_get_erd_value(ErdCode.COOKTOP_STATUS) + + # if we got a status through either mechanism, we can add the entities + if cooktop_status is None: + return [] + + cooktop_entities: List[Entity] = [ + GeCooktopStatusBinarySensor(api, cooktop_status_erd) + ] + + for burner_name, burner_state in cooktop_status.burners.items(): + if burner_state.exists: + prop = _camel_to_snake(burner_name) + cooktop_entities.append( + GeErdPropertyBinarySensor(api, cooktop_status_erd, prop + ".on") + ) + cooktop_entities.append( + GeErdPropertyBinarySensor( + api, + cooktop_status_erd, + prop + ".synchronized", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ) + if not burner_state.on_off_only: + cooktop_entities.append( + GeErdPropertySensor( + api, + cooktop_status_erd, + prop + ".power_pct", + icon_override="mdi:fire", + device_class_override=SensorDeviceClass.POWER_FACTOR, + data_type_override=ErdDataType.INT, + ) + ) + + # Add cooktop lock status + if api.has_erd_code(ErdCode.COOKTOP_GAS_VALVE_LOCK_STATUS): + cooktop_entities.append( + GeErdBinarySensor( + api, + ErdCode.COOKTOP_GAS_VALVE_LOCK_STATUS, + device_class_override=BinarySensorDeviceClass.LOCK, + entity_category=EntityCategory.DIAGNOSTIC, + ) + ) + + # Add cooktop lock control + if api.has_erd_code(ErdCode.COOKTOP_GAS_VALVE_LOCK_CONTROL): + cooktop_entities.append( + GeErdButton( + api, + ErdCode.COOKTOP_GAS_VALVE_LOCK_CONTROL, + entity_category=EntityCategory.CONFIG, + ) + ) + + # Add cooktop elapsed cook time + if api.has_erd_code(ErdCode.COOKTOP_ELAPSED_COOK_TIME): + cooktop_entities.append( + GeErdSensor( + api, + ErdCode.COOKTOP_ELAPSED_COOK_TIME, + suggested_uom="h", + ) + ) + + return cooktop_entities + + +def _camel_to_snake(value: str) -> str: + return "".join(["_" + c.lower() if c.isupper() else c for c in value]).lstrip("_") + + +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_entities = build_cooktop_entities(self) + + if self.has_erd_code(ErdCode.UPPER_OVEN_KITCHEN_TIMER): + cooktop_entities.append( + GeErdTimerSensor( + self, + ErdCode.UPPER_OVEN_KITCHEN_TIMER, + erd_override="cooktop_kitchen_timer", + suggested_uom="h", + ) + ) + cooktop_entities.append( + GeErdTimerNumber( + self, + ErdCode.UPPER_OVEN_KITCHEN_TIMER, + erd_override="cooktop_kitchen_timer", + max_value=599 + ) + ) + + return base_entities + cooktop_entities diff --git a/custom_components/ge_home/devices/dehumidifier.py b/custom_components/ge_home/devices/dehumidifier.py new file mode 100644 index 0000000..81d873a --- /dev/null +++ b/custom_components/ge_home/devices/dehumidifier.py @@ -0,0 +1,44 @@ +import logging +from typing import List + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.const import EntityCategory +from homeassistant.helpers.entity import Entity +from gehomesdk import ( + ErdCode, + ErdApplianceType +) + +from .base import ApplianceApi +from ..entities import ( + GeErdSensor, + GeErdPropertyBinarySensor, + 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), + GeErdPropertyBinarySensor(self, ErdCode.DHUM_MAINTENANCE, "empty_bucket", device_class_override=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC), + GeErdPropertyBinarySensor(self, ErdCode.DHUM_MAINTENANCE, "clean_filter", device_class_override=BinarySensorDeviceClass.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..86d56b3 --- /dev/null +++ b/custom_components/ge_home/devices/dishwasher.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, ErdRemoteCommand + +from .base import ApplianceApi +from ..entities import GeErdSensor, GeErdBinarySensor, GeErdPropertySensor, GeErdNumber, GeErdTimerNumber, 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) + ] + ) + + # add delay start if supported + if self.has_erd_code(ErdCode.DISHWASHER_DELAY_START_MINUTES): + dishwasher_entities.append( + GeErdTimerNumber(self, ErdCode.DISHWASHER_DELAY_START_MINUTES, min_value=0, max_value=1440) + ) + + 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..f049842 --- /dev/null +++ b/custom_components/ge_home/devices/dryer.py @@ -0,0 +1,66 @@ +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, 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..c3e6eb8 --- /dev/null +++ b/custom_components/ge_home/devices/espresso_maker.py @@ -0,0 +1,36 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +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.DIAGNOSTIC), + GeErdButton(self, ErdCode.CCM_START_DESCALING, entity_category=EntityCategory.DIAGNOSTIC), + GeErdBinarySensor(self, ErdCode.CCM_OUT_OF_WATER, device_class_override=BinarySensorDeviceClass.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..aefdb72 --- /dev/null +++ b/custom_components/ge_home/devices/hood.py @@ -0,0 +1,82 @@ +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 ( + GeHoodFan, + GeHoodLight, + 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, enabled_default=False)) + if light_availability is not None and light_availability.is_available: + hood_entities.append(GeHoodLightLevelSelect(self, ErdCode.HOOD_LIGHT_LEVEL, enabled_default=False)) + + #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, enabled_default=False)) + 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, enabled_default=False)) + + #native fan entity for Home Assistant / HomeKit + if available_fan_speeds is not None and available_fan_speeds > 0 and actual_fan_speed is not None: + hood_entities.append(GeHoodFan(self, ErdCode.HOOD_ACTUAL_FAN_SPEED, ErdCode.HOOD_REQUESTED_FAN_SPEED)) + elif fan_availability is not None and fan_availability.is_available: + hood_entities.append(GeHoodFan(self, ErdCode.HOOD_FAN_SPEED)) + + #native light entity for Home Assistant / HomeKit + if available_light_levels is not None and available_light_levels > 0 and actual_light_level is not None: + hood_entities.append(GeHoodLight(self, ErdCode.HOOD_ACTUAL_LIGHT_LEVEL, ErdCode.HOOD_REQUESTED_LIGHT_LEVEL)) + elif light_availability is not None and light_availability.is_available: + hood_entities.append(GeHoodLight(self, ErdCode.HOOD_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..26b8da4 --- /dev/null +++ b/custom_components/ge_home/devices/microwave.py @@ -0,0 +1,59 @@ +import logging +from typing import List + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +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, + GeErdTimerNumber +) + +_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: List[Entity] = [ + 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=BinarySensorDeviceClass.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"), + GeErdTimerNumber(self, ErdCode.MICROWAVE_KITCHEN_TIMER, max_value=255), + ] + + 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..83191d6 --- /dev/null +++ b/custom_components/ge_home/devices/oim.py @@ -0,0 +1,42 @@ +import logging +from typing import List + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +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=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC), + GeErdBinarySensor(self, ErdCode.OIM_NEEDS_DESCALING, device_class_override=BinarySensorDeviceClass.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..ffdd180 --- /dev/null +++ b/custom_components/ge_home/devices/oven.py @@ -0,0 +1,307 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +from homeassistant.helpers.entity import Entity +from gehomesdk import ( + ErdCode, + ErdApplianceType, + OvenConfiguration, + ErdOvenLightLevel, + ErdOvenLightLevelAvailability, + ErdOvenWarmingState, +) + +from .base import ApplianceApi +from .cooktop import build_cooktop_entities +from ..entities import ( + GeErdSensor, + GeErdTimerSensor, + GeErdTimerNumber, + GeErdBinarySensor, + 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 + ) + + 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}") + oven_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" + ), + GeErdTimerNumber(self, ErdCode.LOWER_OVEN_KITCHEN_TIMER), + 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", + ), + GeErdTimerNumber( + self, + ErdCode.UPPER_OVEN_KITCHEN_TIMER, + self._single_name( + ErdCode.UPPER_OVEN_KITCHEN_TIMER, + not oven_config.has_lower_oven + ), + ), + 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, + ) + ) + + cooktop_entities = build_cooktop_entities(self) + + 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 _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..2d23fed --- /dev/null +++ b/custom_components/ge_home/devices/pac.py @@ -0,0 +1,48 @@ +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, GeErdSelect, ErdOnOffBoolConverter, TurboQuietModeOptionsConverter + +_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"), + ] + + if self.has_erd_code(ErdCode.AC_TURBO_QUIET_MODE): + available_modes = self.try_get_erd_value(ErdCode.AC_AVAILABLE_TURBO_QUIET_MODES) + pac_entities.append( + GeErdSelect( + self, + ErdCode.AC_TURBO_QUIET_STATUS, + TurboQuietModeOptionsConverter( + has_turbo=available_modes.has_turbo if available_modes else True, + has_quiet=available_modes.has_quiet if available_modes else True, + ), + control_erd_code=ErdCode.AC_TURBO_QUIET_MODE, + icon_override="mdi:fan-speed-2", + entity_category=EntityCategory.CONFIG, + ) + ) + + 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..2188ee8 --- /dev/null +++ b/custom_components/ge_home/devices/sac.py @@ -0,0 +1,54 @@ +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, GeErdSelect, ErdOnOffBoolConverter, TurboQuietModeOptionsConverter + +_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)) + + + if self.has_erd_code(ErdCode.AC_TURBO_QUIET_MODE): + available_modes = self.try_get_erd_value(ErdCode.AC_AVAILABLE_TURBO_QUIET_MODES) + sac_entities.append( + GeErdSelect( + self, + ErdCode.AC_TURBO_QUIET_STATUS, + TurboQuietModeOptionsConverter( + has_turbo=available_modes.has_turbo if available_modes else True, + has_quiet=available_modes.has_quiet if available_modes else True, + ), + control_erd_code=ErdCode.AC_TURBO_QUIET_MODE, + icon_override="mdi:fan-speed-2", + 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..b5d8a5f --- /dev/null +++ b/custom_components/ge_home/devices/ucim.py @@ -0,0 +1,43 @@ +import logging +from typing import List + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +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.UNDER_COUNTER_ICE_MAKER + + def get_all_entities(self) -> List[Entity]: + base_entities = super().get_all_entities() + + ucim_entities = [ + GeErdSensor(self, ErdCode.OIM_STATUS, entity_category=EntityCategory.DIAGNOSTIC), + GeErdBinarySensor(self, ErdCode.OIM_FILTER_STATUS, device_class_override=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC), + GeErdBinarySensor(self, ErdCode.OIM_NEEDS_DESCALING, device_class_override=BinarySensorDeviceClass.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=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC), + ] + + entities = base_entities + ucim_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..9562757 --- /dev/null +++ b/custom_components/ge_home/devices/wac.py @@ -0,0 +1,49 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.helpers.entity import Entity +from gehomesdk import ErdCode, ErdApplianceType + +from .base import ApplianceApi +from ..entities import GeWacClimate, GeErdSensor, GeErdBinarySensor, GeErdSwitch, GeErdSelect, ErdOnOffBoolConverter, TurboQuietModeOptionsConverter + +_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=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC), + ] + if self.has_erd_code(ErdCode.AC_TURBO_QUIET_MODE): + available_modes = self.try_get_erd_value(ErdCode.AC_AVAILABLE_TURBO_QUIET_MODES) + wac_entities.append( + GeErdSelect( + self, + ErdCode.AC_TURBO_QUIET_STATUS, + TurboQuietModeOptionsConverter( + has_turbo=available_modes.has_turbo if available_modes else True, + has_quiet=available_modes.has_quiet if available_modes else True, + ), + control_erd_code=ErdCode.AC_TURBO_QUIET_MODE, + icon_override="mdi:fan-speed-2", + entity_category=EntityCategory.CONFIG, + ) + ) + + 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..e067772 --- /dev/null +++ b/custom_components/ge_home/devices/water_filter.py @@ -0,0 +1,41 @@ +import logging +from typing import List + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import SensorDeviceClass +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=BinarySensorDeviceClass.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=SensorDeviceClass.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=BinarySensorDeviceClass.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..e242957 --- /dev/null +++ b/custom_components/ge_home/devices/water_softener.py @@ -0,0 +1,41 @@ +import logging +from typing import List + +from homeassistant.const import EntityCategory +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +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=BinarySensorDeviceClass.MOISTURE, entity_category=EntityCategory.DIAGNOSTIC), + GeErdSensor(self, ErdCode.WH_FILTER_DAY_USAGE, device_class_override=SensorDeviceClass.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..b0916bc --- /dev/null +++ b/custom_components/ge_home/entities/__init__.py @@ -0,0 +1,14 @@ +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 * +from .laundry 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..04960cf --- /dev/null +++ b/custom_components/ge_home/entities/ac/__init__.py @@ -0,0 +1,5 @@ +from .ge_wac_climate import GeWacClimate +from .ge_sac_climate import GeSacClimate +from .ge_pac_climate import GePacClimate +from .ge_biac_climate import GeBiacClimate +from .turbo_quiet_options import TurboQuietModeOptionsConverter 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..2d986c5 --- /dev/null +++ b/custom_components/ge_home/entities/ac/ge_sac_climate.py @@ -0,0 +1,83 @@ +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.AUTO, 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()) + if self._available_modes and self._available_modes.has_heat: + auto_mode = ErdAcOperationMode.AUTO + else: + auto_mode = ErdAcOperationMode.ENERGY_SAVER + return { + HVACMode.AUTO: auto_mode, + 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/ac/turbo_quiet_options.py b/custom_components/ge_home/entities/ac/turbo_quiet_options.py new file mode 100644 index 0000000..9356aee --- /dev/null +++ b/custom_components/ge_home/entities/ac/turbo_quiet_options.py @@ -0,0 +1,38 @@ +from typing import Any, List, Optional + +from gehomesdk import ErdAcTurboQuietMode + +from ..common import OptionsConverter + +TURBO_QUIET_MODE_NORMAL = "Normal" +TURBO_QUIET_MODE_TURBO = "Turbo" +TURBO_QUIET_MODE_QUIET = "Quiet" + + +class TurboQuietModeOptionsConverter(OptionsConverter): + def __init__(self, has_turbo: bool = True, has_quiet: bool = True): + self._has_turbo = has_turbo + self._has_quiet = has_quiet + + @property + def options(self) -> List[str]: + opts = [TURBO_QUIET_MODE_NORMAL] + if self._has_turbo: + opts.append(TURBO_QUIET_MODE_TURBO) + if self._has_quiet: + opts.append(TURBO_QUIET_MODE_QUIET) + return opts + + def from_option_string(self, value: str) -> Any: + return { + TURBO_QUIET_MODE_NORMAL: ErdAcTurboQuietMode.NORMAL, + TURBO_QUIET_MODE_TURBO: ErdAcTurboQuietMode.TURBO, + TURBO_QUIET_MODE_QUIET: ErdAcTurboQuietMode.QUIET, + }.get(value, ErdAcTurboQuietMode.NORMAL) + + def to_option_string(self, value: Any) -> Optional[str]: + return { + ErdAcTurboQuietMode.NORMAL: TURBO_QUIET_MODE_NORMAL, + ErdAcTurboQuietMode.TURBO: TURBO_QUIET_MODE_TURBO, + ErdAcTurboQuietMode.QUIET: TURBO_QUIET_MODE_QUIET, + }.get(value) 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..f6946c9 --- /dev/null +++ b/custom_components/ge_home/entities/common/__init__.py @@ -0,0 +1,20 @@ +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_fan import GeErdFan +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_erd_timer_number import GeErdTimerNumber +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..9e6fc0a --- /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[BinarySensorDeviceClass] = 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_fan.py b/custom_components/ge_home/entities/common/ge_erd_fan.py new file mode 100644 index 0000000..115d07f --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_fan.py @@ -0,0 +1,65 @@ +import logging +from typing import Optional, Any +from propcache.api import cached_property + +from homeassistant.components.fan import ( + FanEntity, + FanEntityFeature +) +from homeassistant.const import EntityCategory +from gehomesdk import ErdCodeType + +from ...devices import ApplianceApi +from .ge_erd_entity import GeErdEntity + +_LOGGER = logging.getLogger(__name__) + +class GeErdFan(GeErdEntity, FanEntity): + """Fans for ERD codes.""" + + 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, 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 + + @property + def supported_features(self) -> FanEntityFeature: # type: ignore + return FanEntityFeature.SET_SPEED | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF + + @property + def is_on(self) -> bool: # type: ignore + """Return True if fan is on.""" + try: + val: Any = self.appliance.get_erd_value(self.erd_code) + return bool(val > 0) if val is not None else False + except (KeyError, TypeError): + return False + + @property + def percentage(self) -> int: # type: ignore + """Return the current speed percentage.""" + try: + val: Any = self.appliance.get_erd_value(self.erd_code) + return int(val) if val is not None else 0 + except (KeyError, ValueError, TypeError): + return 0 + + async def async_turn_on(self, percentage: int | None = None, preset_mode: str | None = None, **kwargs: Any) -> None: + """Turn the fan on.""" + if percentage is None: + percentage = self.percentage or 100 + await self.async_set_percentage(percentage) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + await self.async_set_percentage(0) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + await self.appliance.async_set_erd_value(self.erd_code, percentage) 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..1394990 --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_property_binary_sensor.py @@ -0,0 +1,56 @@ +from propcache.api import cached_property +from typing import Optional + +import magicattr +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +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[BinarySensorDeviceClass] = 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..3da909e --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_property_sensor.py @@ -0,0 +1,66 @@ +from propcache.api import cached_property +from typing import Optional +import magicattr + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +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[SensorDeviceClass] = None, + state_class_override: Optional[SensorStateClass] = 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..2c0ddf6 --- /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[SensorDeviceClass] = None, + state_class_override: Optional[SensorStateClass] = 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[SensorStateClass]: + 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[SensorStateClass]: + 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_number.py b/custom_components/ge_home/entities/common/ge_erd_timer_number.py new file mode 100644 index 0000000..3c50225 --- /dev/null +++ b/custom_components/ge_home/entities/common/ge_erd_timer_number.py @@ -0,0 +1,56 @@ +import logging +from datetime import timedelta +from typing import Optional + +from homeassistant.components.number import NumberDeviceClass, NumberMode +from homeassistant.const import EntityCategory, UnitOfTime +from gehomesdk import ErdCodeType + +from .ge_erd_number import GeErdNumber +from ...devices import ApplianceApi + +_LOGGER = logging.getLogger(__name__) + + +class GeErdTimerNumber(GeErdNumber): + """GeErdNumber subclass for timer/timedelta ERDs. Values are displayed and set in minutes.""" + + def __init__( + self, + api: ApplianceApi, + erd_code: ErdCodeType, + erd_override: Optional[str] = None, + min_value: float = 0, + max_value: float = 1440, + step_value: float = 1, + entity_category: Optional[EntityCategory] = None, + ): + super().__init__( + api, + erd_code, + erd_override=erd_override, + device_class_override=NumberDeviceClass.DURATION, + uom_override=UnitOfTime.MINUTES, + min_value=min_value, + max_value=max_value, + step_value=step_value, + mode=NumberMode.BOX, + entity_category=entity_category, + ) + + def _convert_value_from_device(self, value) -> Optional[float]: + if value is None: + return None + try: + if isinstance(value, timedelta): + return float(round(value.total_seconds() / 60)) + return float(round(value)) + except Exception: + return None + + async def async_set_native_value(self, value: float) -> None: + td = timedelta(minutes=int(round(value))) + try: + await self.appliance.async_set_erd_value(self.erd_code, td) + except Exception: + _LOGGER.warning(f"Could not set {self.name} to {value} minutes") 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..247fe35 --- /dev/null +++ b/custom_components/ge_home/entities/dehumidifier/dehumidifier_fan_speed_sensor.py @@ -0,0 +1,45 @@ +from typing import Optional + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +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[SensorDeviceClass] = None, + state_class_override: Optional[SensorStateClass] = 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..29f55ae --- /dev/null +++ b/custom_components/ge_home/entities/hood/__init__.py @@ -0,0 +1,4 @@ +from .ge_hood_fan import GeHoodFan +from .ge_hood_fan_speed import GeHoodFanSpeedSelect +from .ge_hood_light import GeHoodLight +from .ge_hood_light_level import GeHoodLightLevelSelect diff --git a/custom_components/ge_home/entities/hood/ge_hood_fan.py b/custom_components/ge_home/entities/hood/ge_hood_fan.py new file mode 100644 index 0000000..00f63bd --- /dev/null +++ b/custom_components/ge_home/entities/hood/ge_hood_fan.py @@ -0,0 +1,156 @@ +import logging +from typing import List, Optional, Any + +from homeassistant.components.fan import FanEntityFeature +from propcache.api import cached_property +from gehomesdk import ErdCodeType, ErdHoodFanSpeed +from ...const import DOMAIN +from ...devices import ApplianceApi +from ..common import GeErdFan +from .ge_hood_fan_options import detect_hood_fan_speed + +_LOGGER = logging.getLogger(__name__) + +class GeHoodFan(GeErdFan): + """Fan entity for GE hood fan speed controls.""" + + def __init__(self, api: ApplianceApi, erd_code: ErdCodeType, control_erd_code: Optional[ErdCodeType] = None): + self._availability, self._converter = detect_hood_fan_speed(api) + super().__init__(api, erd_code) + self._control_erd_code = control_erd_code + self._requested_percentage: int | None = None + + @cached_property + def name(self) -> Optional[str]: + return f"{self.serial_or_mac} Hood Fan" + + @cached_property + def unique_id(self) -> Optional[str]: + return f"{DOMAIN}_{self.serial_or_mac}_hood_fan" + + @property + def icon(self) -> str | None: + return "mdi:fan" + + @property + def supported_features(self) -> FanEntityFeature: + features = FanEntityFeature.SET_SPEED | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF + if self._boost_option is not None: + features |= FanEntityFeature.PRESET_MODE + return features + + @cached_property + def speed_count(self) -> int: + return len(self._speed_options) + + @property + def is_on(self) -> bool: + off_str = str(ErdHoodFanSpeed.OFF.stringify()) + return self.current_option.lower() != off_str.lower() + + @property + def percentage(self) -> int: + option = self.current_option + off_str = str(ErdHoodFanSpeed.OFF.stringify()) + if option.lower() == off_str.lower(): + return 0 + if self._boost_option is not None and option.lower() == self._boost_option.lower(): + return 100 + + try: + opts = self._speed_options + speed_index = opts.index(option) + 1 + return (speed_index * 100) // self.speed_count + except ValueError: + _LOGGER.debug(f"Unable to map hood fan speed {option} to percentage") + return 0 + + @property + def preset_mode(self) -> str | None: # pyright: ignore[reportIncompatibleVariableOverride] + if self._boost_option is not None and self.current_option.lower() == self._boost_option.lower(): + return self._boost_option + return None + + @cached_property + def preset_modes(self) -> list[str] | None: + if self._boost_option is not None: + return [self._boost_option] + return None + + @property + def current_option(self) -> str: + try: + val = self._converter.to_option_string(self.appliance.get_erd_value(self.erd_code)) + if val is not None: + return str(val) + except Exception: + pass + return str(ErdHoodFanSpeed.OFF.stringify()) + + @cached_property + def _speed_options(self) -> List[str]: + off_str = str(ErdHoodFanSpeed.OFF.stringify()) + boost_str = str(ErdHoodFanSpeed.BOOST.stringify()) + return [ + option + for option in self._converter.options + if option.lower() not in ( + off_str.lower(), + boost_str.lower(), + ) + ] + + @cached_property + def _boost_option(self) -> str | None: + boost_str = str(ErdHoodFanSpeed.BOOST.stringify()) + return next( + ( + option + for option in self._converter.options + if option.lower() == boost_str.lower() + ), + None + ) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the hood fan speed percentage.""" + self._requested_percentage = percentage + option = self._option_from_percentage(percentage) + if option != self.current_option: + _LOGGER.debug(f"Setting hood fan from {self.current_option} to {option}") + await self.appliance.async_set_erd_value(self._writeable_erd_code, self._converter.from_option_string(option)) + + async def async_turn_on(self, percentage: int | None = None, preset_mode: str | None = None, **kwargs: Any) -> None: + """Turn the hood fan on.""" + if percentage is None: + percentage = self._requested_percentage or self.percentage or 50 + await self.async_set_percentage(percentage) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the hood fan off.""" + await self.async_set_percentage(0) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the hood fan preset mode.""" + if self._boost_option is None or preset_mode.lower() != self._boost_option.lower(): + raise ValueError(f"Unsupported hood fan preset mode: {preset_mode}") + + self._requested_percentage = 100 + if self.current_option.lower() != self._boost_option.lower(): + _LOGGER.debug(f"Setting hood fan from {self.current_option} to {self._boost_option}") + await self.appliance.async_set_erd_value(self._writeable_erd_code, self._converter.from_option_string(self._boost_option)) + + def _option_from_percentage(self, percentage: int) -> str: + if percentage <= 0 or self.speed_count == 0: + return str(ErdHoodFanSpeed.OFF.stringify()) + + speed_index = round((percentage * self.speed_count) / 100) + speed_index = min(max(speed_index, 1), self.speed_count) + return self._speed_options[speed_index - 1] + + @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/hood/ge_hood_fan_options.py b/custom_components/ge_home/entities/hood/ge_hood_fan_options.py new file mode 100644 index 0000000..4925e82 --- /dev/null +++ b/custom_components/ge_home/entities/hood/ge_hood_fan_options.py @@ -0,0 +1,54 @@ +import logging +from typing import List, Any, Optional, Tuple +from gehomesdk import ErdHoodFanSpeedAvailability, ErdHoodFanSpeed, ErdCode +from ...devices import ApplianceApi +from ..common import OptionsConverter + +_LOGGER = logging.getLogger(__name__) + +class HoodFanSpeedOptionsConverter(OptionsConverter): + def __init__(self, availability: ErdHoodFanSpeedAvailability): + super().__init__() + self.availability = availability + self.excluded_speeds: List[ErdHoodFanSpeed] = [] + 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 [str(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 Exception: + _LOGGER.warning(f"Could not set hood fan speed to {value.upper()}") + return ErdHoodFanSpeed.OFF + + def to_option_string(self, value: Any) -> Optional[str]: + try: + if value is not None and hasattr(value, "stringify"): + return str(value.stringify()) + except Exception: + pass + return str(ErdHoodFanSpeed.OFF.stringify()) + +def detect_hood_fan_speed(api: ApplianceApi) -> Tuple[ErdHoodFanSpeedAvailability, OptionsConverter]: + if (a := api.try_get_erd_value(ErdCode.HOOD_FAN_SPEED_AVAILABILITY)) is not None: + return a, HoodFanSpeedOptionsConverter(a) + + if (fs := api.try_get_erd_value(ErdCode.HOOD_AVAILABLE_FAN_SPEEDS)) is not None: + a = ErdHoodFanSpeedAvailability.from_count(fs) + return a, HoodFanSpeedOptionsConverter(a) + + a = ErdHoodFanSpeedAvailability(off_available=True) + return a, HoodFanSpeedOptionsConverter(a) + 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..ca5ced0 --- /dev/null +++ b/custom_components/ge_home/entities/hood/ge_hood_fan_speed.py @@ -0,0 +1,17 @@ +from typing import Optional +from gehomesdk import ErdCodeType +from ...devices import ApplianceApi +from ..common import GeErdSelect +from .ge_hood_fan_options import detect_hood_fan_speed + +class GeHoodFanSpeedSelect(GeErdSelect): + def __init__( + self, + api: ApplianceApi, + erd_code: ErdCodeType, + control_erd_code: Optional[ErdCodeType] = None, + enabled_default: bool = True + ): + self._availability, converter = detect_hood_fan_speed(api) + self._attr_entity_registry_enabled_default = enabled_default + super().__init__(api, erd_code, converter, control_erd_code=control_erd_code) diff --git a/custom_components/ge_home/entities/hood/ge_hood_light.py b/custom_components/ge_home/entities/hood/ge_hood_light.py new file mode 100644 index 0000000..7319725 --- /dev/null +++ b/custom_components/ge_home/entities/hood/ge_hood_light.py @@ -0,0 +1,112 @@ +import logging +from typing import List, Optional, Any + +from homeassistant.components.light import ATTR_BRIGHTNESS +from propcache.api import cached_property +from gehomesdk import ErdCodeType, ErdHoodLightLevel +from ...const import DOMAIN +from ...devices import ApplianceApi +from ..common import GeErdLight +from .ge_hood_light_options import detect_hood_light_level + +_LOGGER = logging.getLogger(__name__) + +class GeHoodLight(GeErdLight): + """Light entity for GE hood light level controls.""" + + def __init__(self, api: ApplianceApi, erd_code: ErdCodeType, control_erd_code: Optional[ErdCodeType] = None): + super().__init__(api, erd_code) + self._availability, self._converter = detect_hood_light_level(api) + self._control_erd_code = control_erd_code + + @cached_property + def name(self) -> Optional[str]: + return f"{self.serial_or_mac} Hood Light" + + @cached_property + def unique_id(self) -> Optional[str]: + return f"{DOMAIN}_{self.serial_or_mac}_hood_light" + + @property + def icon(self) -> str | None: + return "mdi:lightbulb" + + @property + def brightness(self) -> int: + """Return the brightness of the light.""" + option = self._current_option + if option == self._off_option: + return 0 + + try: + opts = self._light_options + level_index = opts.index(option) + 1 + return round((level_index * 255) / len(opts)) + except ValueError: + _LOGGER.debug(f"Unable to map hood light level {option} to brightness") + return 0 + + @property + def is_on(self) -> bool: + """Return True if light is on.""" + return self._current_option != self._off_option + + @property + def _current_option(self) -> str: + try: + val = self._converter.to_option_string(self.appliance.get_erd_value(self.erd_code)) + if val is not None: + return str(val) + except Exception: + pass + return self._off_option + + @cached_property + def _off_option(self) -> str: + off_str = str(ErdHoodLightLevel.OFF.stringify()) + return next( + ( + option + for option in self._converter.options + if option.lower() == off_str.lower() + ), + off_str + ) + + @cached_property + def _light_options(self) -> List[str]: + return [ + option + for option in self._converter.options + if option.lower() != self._off_option.lower() + ] + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + brightness: int = kwargs.pop(ATTR_BRIGHTNESS, self.brightness or 255) + option = self._option_from_brightness(brightness) + if option != self._current_option: + _LOGGER.debug(f"Setting hood light from {self._current_option} to {option}") + await self.appliance.async_set_erd_value(self._writeable_erd_code, self._converter.from_option_string(option)) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + if self._current_option != self._off_option: + _LOGGER.debug(f"Turning off {self.unique_id}") + await self.appliance.async_set_erd_value(self._writeable_erd_code, self._converter.from_option_string(self._off_option)) + + def _option_from_brightness(self, brightness: int) -> str: + opts = self._light_options + if brightness <= 0 or len(opts) == 0: + return self._off_option + + level_index = round((brightness * len(opts)) / 255) + level_index = min(max(level_index, 1), len(opts)) + return opts[level_index - 1] + + @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/hood/ge_hood_light_level.py b/custom_components/ge_home/entities/hood/ge_hood_light_level.py new file mode 100644 index 0000000..130a87b --- /dev/null +++ b/custom_components/ge_home/entities/hood/ge_hood_light_level.py @@ -0,0 +1,17 @@ +from typing import Optional +from gehomesdk import ErdCodeType +from ...devices import ApplianceApi +from ..common import GeErdSelect +from .ge_hood_light_options import detect_hood_light_level + +class GeHoodLightLevelSelect(GeErdSelect): + def __init__( + self, + api: ApplianceApi, + erd_code: ErdCodeType, + control_erd_code: Optional[ErdCodeType] = None, + enabled_default: bool = True + ): + self._availability, converter = detect_hood_light_level(api) + self._attr_entity_registry_enabled_default = enabled_default + super().__init__(api, erd_code, converter, control_erd_code=control_erd_code) diff --git a/custom_components/ge_home/entities/hood/ge_hood_light_options.py b/custom_components/ge_home/entities/hood/ge_hood_light_options.py new file mode 100644 index 0000000..7ce2967 --- /dev/null +++ b/custom_components/ge_home/entities/hood/ge_hood_light_options.py @@ -0,0 +1,84 @@ +import logging +from typing import List, Any, Optional, Tuple +from gehomesdk import ErdHoodLightLevelAvailability, ErdHoodLightLevel, ErdHoodLightLevelNew, ErdCode +from ...devices import ApplianceApi +from ..common import OptionsConverter + +_LOGGER = logging.getLogger(__name__) + +class HoodLightLevelOptionsConverter(OptionsConverter): + def __init__(self, availability: ErdHoodLightLevelAvailability): + super().__init__() + self.availability = availability + self.excluded_levels: List[ErdHoodLightLevel] = [] + 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 [str(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 Exception: + _LOGGER.warning(f"Could not set hood light level to {value.upper()}") + return ErdHoodLightLevel.OFF + + def to_option_string(self, value: Any) -> Optional[str]: + try: + if value is not None and hasattr(value, "stringify"): + return str(value.stringify()) + except Exception: + pass + return str(ErdHoodLightLevel.OFF.stringify()) + +class HoodLightLevelNewOptionsConverter(OptionsConverter): + def __init__(self, availability: ErdHoodLightLevelAvailability): + super().__init__() + self.availability = availability + self.excluded_levels: List[ErdHoodLightLevelNew] = [] + 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 [str(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 Exception: + _LOGGER.warning(f"Could not set hood light level to {value.upper()}") + return ErdHoodLightLevelNew.OFF + + def to_option_string(self, value: Any) -> Optional[str]: + try: + if value is not None and hasattr(value, "stringify"): + return str(value.stringify()) + except Exception: + pass + return str(ErdHoodLightLevelNew.OFF.stringify()) + +def detect_hood_light_level(api: ApplianceApi) -> Tuple[ErdHoodLightLevelAvailability, OptionsConverter]: + 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) 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..bf5eb7c --- /dev/null +++ b/custom_components/ge_home/entities/oven/const.py @@ -0,0 +1,50 @@ +import bidict + +from homeassistant.components.water_heater import WaterHeaterEntityFeature +from homeassistant.const import STATE_OFF +from gehomesdk import ErdOvenCookMode + +SUPPORT_NONE = WaterHeaterEntityFeature(0) +GE_OVEN_SUPPORT = (WaterHeaterEntityFeature.OPERATION_MODE | WaterHeaterEntityFeature.TARGET_TEMPERATURE) + +OP_MODE_OFF = STATE_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" +OP_MODE_VENT_BAKE = "Vent Bake" + +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, + ErdOvenCookMode.VENT_BAKE: OP_MODE_VENT_BAKE +}) + 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..a53f06e --- /dev/null +++ b/custom_components/ge_home/entities/oven/ge_oven.py @@ -0,0 +1,233 @@ +"""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): + # GE appliances always report temperatures in Fahrenheit regardless of + # the configured measurement system (and may report METRIC while still + # sending Fahrenheit), so we hard-code Fahrenheit for them. + 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 0 + + @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..5825bb2 --- /dev/null +++ b/custom_components/ge_home/exceptions.py @@ -0,0 +1,16 @@ +""" 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 HaMfaRequired(ha_exc.HomeAssistantError): + """Error to indicate MFA is required to complete login.""" +class HaTermsRequired(ha_exc.HomeAssistantError): + """Error to indicate terms of service acceptance is required.""" +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/fan.py b/custom_components/ge_home/fan.py new file mode 100644 index 0000000..4832367 --- /dev/null +++ b/custom_components/ge_home/fan.py @@ -0,0 +1,47 @@ +"""GE Home Fan Entities""" +import logging +from collections.abc import Collection +from typing import Callable, Any + +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 GeErdFan +from .update_coordinator import GeHomeUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable[..., Any]): + """GE Home fans""" + _LOGGER.debug('Adding GE "Fans"') + 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, GeErdFan) + 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 fans 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/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..a57407e --- /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, Any + +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, GeHoodLight +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[..., Any] +): + """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..f02f27c --- /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.5.4","magicattr==0.1.6"], + "codeowners": ["@simbaja"], + "version": "2026.6.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..c16e294 --- /dev/null +++ b/custom_components/ge_home/translations/en.json @@ -0,0 +1,32 @@ +{ + "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", + "mfa_required": "Multi-factor authentication is required; please complete MFA in the SmartHQ app and try again", + "terms_required": "Terms of service acceptance is required; please accept the terms in the SmartHQ app and try again", + "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..811f994 --- /dev/null +++ b/custom_components/ge_home/update_coordinator.py @@ -0,0 +1,691 @@ +"""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", + "fan", + "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 + + self._ensure_appliance_available(appliance) + + 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._ensure_appliance_available(appliance) + 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 _ensure_appliance_available(self, appliance: GeAppliance) -> None: + """Treat successful SmartHQ state traffic as proof the appliance is online.""" + if appliance.available: + return + + _LOGGER.info( + "Marking appliance %s available after receiving a successful state update", + appliance.mac_addr, + ) + appliance.set_available() + + 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..cef3cde --- /dev/null +++ b/info.md @@ -0,0 +1,351 @@ +# 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.6.0'.split('.') | map('int') %} +- Refactored common cooktop logic to apply to both cooktop and oven devices +- Tightened typing for binary sensor and sensor device/state classes +- Improved login error messages now specifically indicate when MFA or Terms of Service acceptance is required +- Removed AC-specific demand response sensors from WAC/BIAC (now provided by the cross-appliance resource sensors) +{% endif %} + +{% 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.6.0'.split('.') | map('int') %} +- Added hood fan and light entities [#507] +- Added cooktop-specific entities (sensors and controls) +- Added kitchen timer to cooktop as number entities (in minutes) +- Added gas cooktop to known device mapping +- Added quiet/turbo mode for AC-capable appliances [#397] +- Added potential support for dishwasher delay start on some models [#434] +- Added cross-appliance resource usage sensors to all supported appliances (instantaneous power, cumulative energy, hot/cold water, gas usage) where the appliance reports them [#335,#492] +{% endif %} + +{% 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.5.0'.split('.') | map('int') %} +- Fixed dehumidifier sensors incorrectly typed (should be binary sensors) +- Fixed typing issue in oven target_temperature +- Avoid returning None for oven target_temperature [#457] +- Allow eco mode access for non-heating split ACs [#474] +- Fix oven off mode to use HA's STATE_OFF constant [#485] +- Restore appliance availability on state update [#495] +- Ignore non-printable serial numbers and fall back to MAC as unique device identifier [#502] +{% endif %} + +{% 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 %}