diff --git a/homeassistant/components/effortlesshome/.pylintrc b/homeassistant/components/effortlesshome/.pylintrc new file mode 100644 index 0000000000000..bd27afc081a26 --- /dev/null +++ b/homeassistant/components/effortlesshome/.pylintrc @@ -0,0 +1,70 @@ +[MASTER] +ignore=tests +ignore-patterns=app_vars +ignored-modules=homeassistant +# Use a conservative default here; 2 should speed up most setups and not hurt +# any too bad. Override on command line as appropriate. +jobs=2 +persistent=no +suggestion-mode=yes +extension-pkg-whitelist=taglib + +[BASIC] +good-names=id,i,j,k,ex,Run,_,fp,T,ev + +[MESSAGES CONTROL] +# Reasons disabled: +# format - handled by black +# locally-disabled - it spams too much +# duplicate-code - unavoidable +# cyclic-import - doesn't test if both import on load +# abstract-class-little-used - prevents from setting right foundation +# unused-argument - generic callbacks and setup methods create a lot of warnings +# too-many-* - are not enforced for the sake of readability +# too-few-* - same as too-many-* +# abstract-method - with intro of async there are always methods missing +# inconsistent-return-statements - doesn't handle raise +# too-many-ancestors - it's too strict. +# wrong-import-order - isort guards this +# fixme - project is in development phase +disable= + format, + abstract-class-little-used, + abstract-method, + cyclic-import, + duplicate-code, + inconsistent-return-statements, + locally-disabled, + not-context-manager, + too-few-public-methods, + too-many-ancestors, + too-many-arguments, + too-many-branches, + too-many-instance-attributes, + too-many-lines, + too-many-locals, + too-many-public-methods, + too-many-return-statements, + too-many-statements, + too-many-boolean-expressions, + unused-argument, + wrong-import-order, + fixme +# enable useless-suppression temporarily every now and then to clean them up +enable= + use-symbolic-message-instead + +[REPORTS] +score=no + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=15 + +[TYPECHECK] +# For attrs +ignored-classes=_CountingAttr + +[FORMAT] +expected-line-ending-format=LFf \ No newline at end of file diff --git a/homeassistant/components/effortlesshome/.vscode/tasks.json b/homeassistant/components/effortlesshome/.vscode/tasks.json new file mode 100644 index 0000000000000..f6b59e8ed0536 --- /dev/null +++ b/homeassistant/components/effortlesshome/.vscode/tasks.json @@ -0,0 +1,102 @@ +{ + "tasks": [ + { + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}/run_core_pr_checks.ps1", + "-Setup", + "-Checks", + "all" + ], + "command": "powershell", + "group": "build", + "label": "EffortlessHome: Setup Core Check Env", + "problemMatcher": [], + "type": "shell" + }, + { + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}/run_core_pr_checks.ps1", + "-Checks", + "all" + ], + "command": "powershell", + "group": "test", + "label": "EffortlessHome: Run Core PR Checks (all)", + "problemMatcher": [], + "type": "shell" + }, + { + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}/run_core_pr_checks.ps1", + "-Checks", + "prek" + ], + "command": "powershell", + "group": "test", + "label": "EffortlessHome: Run prek checks", + "problemMatcher": [], + "type": "shell" + }, + { + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}/run_core_pr_checks.ps1", + "-Checks", + "hassfest" + ], + "command": "powershell", + "group": "test", + "label": "EffortlessHome: Check hassfest", + "problemMatcher": [], + "type": "shell" + }, + { + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}/run_core_pr_checks.ps1", + "-Checks", + "pylint" + ], + "command": "powershell", + "group": "test", + "label": "EffortlessHome: Check pylint", + "problemMatcher": [], + "type": "shell" + }, + { + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}/run_core_pr_checks.ps1", + "-Checks", + "mypy" + ], + "command": "powershell", + "group": "test", + "label": "EffortlessHome: Check mypy", + "problemMatcher": [], + "type": "shell" + } + ], + "version": "2.0.0" +} diff --git a/homeassistant/components/effortlesshome/README.md b/homeassistant/components/effortlesshome/README.md new file mode 100644 index 0000000000000..6c592c677a5da --- /dev/null +++ b/homeassistant/components/effortlesshome/README.md @@ -0,0 +1,165 @@ +# EffortlessHome + +[![GitHub Release][releases-shield]][releases] +[![License][license-shield]](LICENSE) + +![Project Maintenance][maintenance-shield] +[![BuyMeCoffee][buymecoffeebadge]][buymecoffee] + +[![Discord][discord-shield]][discord] +[![Community Forum][forum-shield]][forum] + +EffortlessHome is a simplified Home Assistant integration focused on service-based actions and setup helpers. + +## Features + +- Config flow with account sign-in and system selection. +- Service endpoints for creating alerts/events and maintaining entity metadata. +- Optional deployment helper to copy packaged web resources/config assets. +- Device-class group synchronization at startup. + +## Installation + +### HACS Installation (Recommended) + +1. Open HACS in your Home Assistant instance +2. Add this Github Repo as a custom source +3. Search for "EffortlessHome" +4. Click Install +5. Restart Home Assistant + +### Manual Installation + +1. Copy the `effortlesshome` directory to your `custom_components` folder +2. Restart Home Assistant +3. Add the integration via Configuration > Integrations > Add Integration > EffortlessHome + +## Configuration + +### Initial Setup + +1. After installation, go to Configuration > Integrations +2. Click the "+" button and search for "EffortlessHome" +3. Enter your EffortlessHome account credentials +4. Select the system you want to configure (if you have multiple systems) + +### Required Information + +- **Email**: Your EffortlessHome account email +- **Password**: Your EffortlessHome account password + +### Optional Configuration + +After initial setup, you can configure additional options: + +- **Debug Mode**: Enable debug logging for troubleshooting + +## Services + +The integration provides several services: + +| Service | Description | +|---------|-------------| +| `effortlesshome.clean_motion_files` | Clean old motion snapshot files | +| `effortlesshome.create_alert` | Create an alert record | +| `effortlesshome.create_event` | Create an event for active alarm | +| `effortlesshome.deploy_latest_config` | Deploy latest configuration files | +| `effortlesshome.add_label_to_entity` | Add a label to an entity | +| `effortlesshome.update_entity` | Update entity area assignment | + +## Entities + +This simplified integration does not register dedicated entity platforms. + +## Requirements + +### Dependencies + +This integration requires the following Python packages (automatically installed): + +- `oasira>=0.2.12` +- `gcal-sync>=6.2.0` +- `google-auth>=2.28.0` +- `google-api-python-client>=2.126.0` +- `gTTS>=2.5.0` +- `google-genai==1.29.0` +- `influxdb-client>=1.48.0` + +### Home Assistant Dependencies + +The integration requires the following Home Assistant components: + +- `http` +- `recorder` +- `conversation` + +## Labels + +The integration uses the following labels for entity organization: + +- `Favorite`: Mark favorite entities +- `NotForSecurityMonitoring`: Exclude entities from security monitoring + +## Support + +- **GitHub Issues**: [Report bugs and feature requests](https://github.com/EffortlessHome/EffortlessHome/issues) +- **Discord Community**: [Join our Discord server](https://discord.gg/effortlesshome) +- **Forum**: [Community Forum](https://community.home-assistant.io/t/effortlesshome/) + +## Contributing + +We welcome contributions! Please see our [contributing guidelines](CONTRIBUTING.md) for more information. + +## Run Home Assistant Core PR Checks Locally + +From this `effortlesshome` folder, you can run the same core checks used in Home Assistant CI: + +1. First-time setup (create `.venv` in core and install dev/test dependencies): + + ```powershell + .\run_core_pr_checks.ps1 -Setup -Checks all + ``` + +2. Run all requested checks: + + ```powershell + .\run_core_pr_checks.ps1 -Checks all + ``` + +3. Run individual checks: + + ```powershell + .\run_core_pr_checks.ps1 -Checks prek + .\run_core_pr_checks.ps1 -Checks hassfest + .\run_core_pr_checks.ps1 -Checks pylint + .\run_core_pr_checks.ps1 -Checks mypy + ``` + +This script targets `homeassistant/components/effortlesshome` for pylint/mypy and uses the same `PREK_SKIP` split as the CI workflow so that `prek` does not duplicate `hassfest`, `pylint`, and `mypy`. + +VS Code tasks are also included in `.vscode/tasks.json` for one-click runs. + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Acknowledgments + +- Home Assistant community +- All contributors and testers +- EffortlessHome users + +--- + +**Note**: This integration requires an EffortlessHome account. Sign up at [https://my.effortlesshome.co](https://my.effortlesshome.co) + +[buymecoffee]: https://www.buymecoffee.com/effortlesshome +[buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge +[discord]: https://discord.gg/effortlesshome +[discord-shield]: https://img.shields.io/discord/1234567890?color=7289da&label=Discord&logo=discord&logoColor=white&style=for-the-badge +[forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge&logo=home-assistant +[forum]: https://community.home-assistant.io/ +[license-shield]: https://img.shields.io/github/license/EffortlessHome/EffortlessHome.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/maintenance/yes/2024.svg?style=for-the-badge +[releases-shield]: https://img.shields.io/github/release/EffortlessHome/EffortlessHome.svg?style=for-the-badge +[releases]: https://github.com/EffortlessHome/EffortlessHome/releases \ No newline at end of file diff --git a/homeassistant/components/effortlesshome/__init__.py b/homeassistant/components/effortlesshome/__init__.py new file mode 100644 index 0000000000000..710964a4f1a88 --- /dev/null +++ b/homeassistant/components/effortlesshome/__init__.py @@ -0,0 +1,439 @@ +"""EffortlessHome integration.""" + +from __future__ import annotations + +import logging +import os +import shutil +import time + +from oasira import OasiraAPIClient, OasiraAPIError +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, + label_registry as lr, +) + +from .const import DOMAIN, LABELS, NAME +from .deviceclassgroupsync import async_setup_devicegroup + +_LOGGER = logging.getLogger(__name__) + + +class HASSComponent: + """Hasscomponent.""" + + # Class-level property to hold the hass instance + hass_instance = None + + @classmethod + def set_hass(cls, hass: HomeAssistant) -> None: + """Set Hass.""" + cls.hass_instance = hass + + @classmethod + def get_hass(cls): + """Get Hass.""" + return cls.hass_instance + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up integration from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN]["entry_id"] = entry.entry_id + + system_id = entry.data["system_id"] + customer_id = entry.data["customer_id"] + id_token = entry.data.get("id_token") + + if not system_id: + raise ConfigEntryError("System ID is missing in configuration.") + + if not customer_id: + raise ConfigEntryError("Customer ID is missing in configuration.") + + HASSComponent.set_hass(hass) + + # Initialize API client and fetch customer/system data + async with OasiraAPIClient( + system_id=system_id, + id_token=id_token, + ) as api_client: + try: + parsed_data = await api_client.get_customer_and_system() + + # Fetch plan features for this system + plan_features = None + try: + plan_features = await api_client.get_plan_features_by_system_id() + except OasiraAPIError as pf_exc: + _LOGGER.warning("Failed to fetch plan features: %s", pf_exc) + plan_features = None + + hass.data[DOMAIN] = { + "entry_id": entry.entry_id, + "config_entry": entry, + "fullname": parsed_data["fullname"], + "phonenumber": parsed_data["phonenumber"], + "emailaddress": parsed_data["emailaddress"], + "ha_token": parsed_data["ha_token"], + "ha_url": parsed_data["ha_url"], + "ai_key": parsed_data["ai_key"], + "ai_model": parsed_data["ai_model"], + "email": parsed_data["emailaddress"], + "username": parsed_data["emailaddress"], + "systemid": system_id, + "customerid": customer_id, + "id_token": id_token, + "refresh_token": entry.data.get("refresh_token"), + "influx_url": parsed_data["influx_url"], + "influx_token": parsed_data["influx_token"], + "influx_bucket": parsed_data["influx_bucket"], + "influx_org": parsed_data["influx_org"], + "DaysHistoryToKeep": parsed_data["DaysHistoryToKeep"], + "LowTemperatureWarning": parsed_data["LowTemperatureWarning"], + "HighTemperatureWarning": parsed_data["HighTemperatureWarning"], + "LowHumidityWarning": parsed_data["LowHumidityWarning"], + "HighHumidityWarning": parsed_data["HighHumidityWarning"], + "address_json": parsed_data["address_json"], + "systemphotolurl": parsed_data["systemphotolurl"], + "testmode": parsed_data["testmode"], + "additional_contacts_json": parsed_data["additional_contacts_json"], + "instructions_json": parsed_data["instructions_json"], + "plan": parsed_data["name"], + "plan_features": plan_features, + } + except OasiraAPIError as e: + _LOGGER.error("Failed to fetch customer/system data: %s", e) + if "401" in str(e): + raise ConfigEntryAuthFailed( + "Authentication failed while fetching customer/system data" + ) from e + raise ConfigEntryNotReady( + f"Failed to fetch customer/system data: {e}" + ) from e + + entry.runtime_data = hass.data[DOMAIN] + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, NAME)}, + name=NAME, + manufacturer=NAME, + model=NAME, + ) + + await hass.config_entries.async_forward_entry_setups(entry, ["switch"]) + + register_services(hass) + + # Labels are kept in sync during setup. + label_registry = lr.async_get(hass) + + for desired in LABELS: + try: + label_registry.async_create(desired) + _LOGGER.info("Created missing label: %s", desired) + except ValueError: + # Label already exists → ignore + _LOGGER.info("Label already exists: %s", desired) + + async def after_home_assistant_started(event): + """Call this function after Home Assistant has started.""" + await loaddevicegroups(None) + + # Listen for the 'homeassistant_started' event + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STARTED, after_home_assistant_started + ) + + return True + + +def _deploy_latest_config_sync(hass: HomeAssistant): + """Synchronous helper for deploying config.""" + integration_dir = os.path.dirname(os.path.abspath(__file__)) + + source_themes_dir = os.path.join(integration_dir, "themes") + source_blueprints_dir = os.path.join(integration_dir, "blueprints") + source_dir = os.path.join(integration_dir, "www/effortlesshome") + + target_themes_dir = hass.config.path("themes") + target_dir = hass.config.path("www/effortlesshome") + target_blueprints_dir = hass.config.path("blueprints") + + # Ensure destination directories exist + os.makedirs(target_themes_dir, exist_ok=True) + os.makedirs(target_dir, exist_ok=True) + os.makedirs(target_blueprints_dir, exist_ok=True) + + # Copy entire themes directory including subfolders and files + if os.path.exists(source_themes_dir): + shutil.copytree(source_themes_dir, target_themes_dir, dirs_exist_ok=True) + + if os.path.exists(source_blueprints_dir): + shutil.copytree( + source_blueprints_dir, target_blueprints_dir, dirs_exist_ok=True + ) + + if os.path.exists(source_dir): + shutil.copytree(source_dir, target_dir, dirs_exist_ok=True) + + +async def deploy_latest_config(hass: HomeAssistant): + """Deploy latest: theme, cards, blueprints, etc.""" + _LOGGER.info("Deploying latest configuration files") + await hass.async_add_executor_job(_deploy_latest_config_sync, hass) + _LOGGER.info("Configuration deployment complete") + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + + await hass.config_entries.async_unload_platforms(entry, ["switch"]) + + # Unregister the notify service + hass.services.async_remove("effortlesshome", "clean_motion_files") + hass.services.async_remove("effortlesshome", "create_event") + hass.services.async_remove("effortlesshome", "update_entity") + hass.services.async_remove("effortlesshome", "create_alert") + hass.services.async_remove("effortlesshome", "deploy_latest_config") + hass.services.async_remove("effortlesshome", "add_label_to_entity") + + return True + + +async def add_label_to_entity(call: ServiceCall) -> None: + """Add a label to an entity.""" + entity_id = call.data.get("entity_id") + label = call.data.get("label") + + if not entity_id or not label: + _LOGGER.error( + "Both entity_id and label are required for add_label_to_entity service" + ) + return + + hass = HASSComponent.get_hass() + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(entity_id) + + if not entity_entry: + _LOGGER.error("Entity not found: %s", entity_id) + return + + new_labels = set(entity_entry.labels) + new_labels.add(label) + + ent_reg.async_update_entity(entity_id, labels=new_labels) + _LOGGER.info("Added label '%s' to entity '%s'", label, entity_id) + + +@callback +def register_services(hass: HomeAssistant) -> None: + """Register effortlesshome services.""" + + hass.services.async_register(DOMAIN, "clean_motion_files", clean_motion_files) + + # Register our service with Home Assistant. + hass.services.async_register(DOMAIN, "create_event", create_event) + + hass.services.async_register(DOMAIN, "update_entity", update_entity) + + hass.services.async_register(DOMAIN, "create_alert", create_alert) + + hass.services.async_register( + DOMAIN, "deploy_latest_config", handle_deploy_latest_config + ) + + hass.services.async_register( + DOMAIN, + "add_label_to_entity", + add_label_to_entity, + schema=vol.Schema( + {vol.Required("entity_id"): cv.entity_id, vol.Required("label"): cv.string} + ), + ) + + +async def update_entity(call): + """Handle the service call.""" + entity_id = call.data.get("entity_id") + new_area = call.data.get("area_id") + + hass = HASSComponent.get_hass() + ent_reg = er.async_get(hass) + + ent_reg.async_update_entity(entity_id, area_id=new_area) + + +async def loaddevicegroups(calldata) -> None: + """Load device groups.""" + hass = HASSComponent.get_hass() + await async_setup_devicegroup(hass) + + +async def create_event(call: ServiceCall) -> None: + """Create event.""" + _LOGGER.info("Create event calldata: %s", call.data) + + hass = HASSComponent.get_hass() + + entity_id = call.data.get("entity_id") + if not entity_id: + _LOGGER.error("The entity_id is required for create_event service") + return None + + devicestate = hass.states.get(entity_id) + sensor_device_class = None + sensor_device_name = None + + if devicestate and devicestate.attributes.get("friendly_name"): + sensor_device_name = devicestate.attributes["friendly_name"] + + if devicestate and devicestate.attributes.get("device_class"): + sensor_device_class = devicestate.attributes["device_class"] + + if sensor_device_class is not None and sensor_device_name is not None: + alarmstate = hass.data[DOMAIN].get("alarm_id") + + if alarmstate and alarmstate != "": + alarmstatus = hass.data[DOMAIN].get("alarmstatus") + + if alarmstatus == "ACTIVE": + alarmid = alarmstate + _LOGGER.info("Alarm id: %s", alarmid) + + # Call the API to create event + systemid = hass.data[DOMAIN].get("systemid") + id_token = hass.data[DOMAIN].get("id_token") + + event_data = { + "sensor_device_class": sensor_device_class, + "sensor_device_name": sensor_device_name, + } + + _LOGGER.info("Calling create event API with payload: %s", event_data) + + async with OasiraAPIClient( + system_id=systemid, + id_token=id_token, + ) as api_client: + try: + result = await api_client.create_event(alarmid, event_data) + _LOGGER.info("API response content: %s", result) + except OasiraAPIError as e: + _LOGGER.error("Failed to create event: %s", e) + return None + else: + return result + return None + return None + return None + + +async def create_alert(call: ServiceCall) -> None: + """Create alert.""" + _LOGGER.info("Create alert calldata: %s", call.data) + + hass = HASSComponent.get_hass() + alert_type = call.data.get("alert_type") + alert_description = call.data.get("alert_description") + status = call.data.get("status") + + if not alert_type or not alert_description or not status: + _LOGGER.error( + "The alert_type, alert_description, and status are required for create_alert service" + ) + return None + + alert_data = { + "alert_type": alert_type, + "alert_description": alert_description, + "status": status, + } + + # Call the API to create alert + systemid = hass.data[DOMAIN].get("systemid") + id_token = hass.data[DOMAIN].get("id_token") + + _LOGGER.info("Calling alert API with payload: %s", alert_data) + + async with OasiraAPIClient( + system_id=systemid, + id_token=id_token, + ) as api_client: + try: + result = await api_client.create_alert(alert_data) + _LOGGER.info("API response content: %s", result) + except OasiraAPIError as e: + _LOGGER.error("Failed to create alert: %s", e) + return None + else: + return result + + +def _clean_motion_files_sync(age: int) -> tuple[int, list[str]]: + """Delete snapshot files older than age days from /media/snapshots.""" + snapshots_dir = "/media/snapshots" + cutoff_seconds = age * 86400 + now = time.time() + removed_count = 0 + errors: list[str] = [] + + if not os.path.isdir(snapshots_dir): + return 0, [f"Directory not found: {snapshots_dir}"] + + for root, _dirs, files in os.walk(snapshots_dir): + for file_name in files: + file_path = os.path.join(root, file_name) + try: + age_seconds = now - os.path.getmtime(file_path) + if age_seconds > cutoff_seconds: + os.remove(file_path) + removed_count += 1 + except OSError as err: + errors.append(f"{file_path}: {err}") + + return removed_count, errors + + +async def clean_motion_files(call: ServiceCall) -> None: + """Delete old snapshots from /media/snapshots.""" + age = call.data.get("age", 30) + + if not isinstance(age, int) or age < 1: + _LOGGER.warning("Invalid age value %s, using default 30 days", age) + age = 30 + + removed_count, errors = await HASSComponent.get_hass().async_add_executor_job( + _clean_motion_files_sync, age + ) + + if errors: + _LOGGER.error("Failed to clean some motion files: %s", "; ".join(errors)) + else: + _LOGGER.info( + "Successfully deleted %s snapshots older than %s days", + removed_count, + age, + ) + + +async def handle_deploy_latest_config(call: ServiceCall) -> None: + """Handle the service call.""" + hass = HASSComponent.get_hass() + + await deploy_latest_config(hass) diff --git a/homeassistant/components/effortlesshome/auto_area.py b/homeassistant/components/effortlesshome/auto_area.py new file mode 100644 index 0000000000000..545cd8e5c5aaf --- /dev/null +++ b/homeassistant/components/effortlesshome/auto_area.py @@ -0,0 +1,100 @@ +"""Core area functionality.""" + +from __future__ import annotations + +import logging + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) +from homeassistant.util import slugify + +from .const import DOMAIN, NAME, RELEVANT_DOMAINS +from .ha_helpers import get_all_entities, is_valid_entity + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +class AutoAreasError(Exception): + """Exception to indicate a general API error.""" + + +class AutoArea: + """Class to manage fetching data from the API.""" + + # config_entry: ConfigEntry + area_id = "" + + # def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, areaid) -> None: + """Initialize.""" + _LOGGER.info('🤖 Auto Area "%s" ', areaid) + self.hass = hass + # self.config_entry = entry + + self.area_registry = ar.async_get(self.hass) + self.device_registry = dr.async_get(self.hass) + self.entity_registry = er.async_get(self.hass) + + self.area_id = areaid + self.area: ar.AreaEntry | None = self.area_registry.async_get_area( + self.area_id or "" + ) + + self.auto_lights = None + + async def async_initialize(self): + """Subscribe to area changes and reload if necessary.""" + _LOGGER.info("%s: Initializing after HA start", self.area_name) + + def cleanup(self): + """Deinitialize this area.""" + _LOGGER.debug("%s: Disabling area control", self.area_name) + if self.auto_lights: + self.auto_lights.cleanup() + + def get_valid_entities(self) -> list[er.RegistryEntry]: + """Return all valid and relevant entities for this area.""" + return [ + entity + for entity in get_all_entities( + self.entity_registry, + self.device_registry, + self.area_id or "", + RELEVANT_DOMAINS, + ) + if is_valid_entity(self.hass, entity) + ] + + def get_area_entity_ids(self, device_classes: list[str]) -> list[str]: + """Return all entity ids in a list of device classes.""" + return [ + entity.entity_id + for entity in self.get_valid_entities() + if entity.device_class in device_classes + or entity.original_device_class in device_classes + ] + + @property + def device_info(self) -> dr.DeviceInfo: + """Information about this device.""" + return { + "identifiers": {(DOMAIN, NAME)}, + "name": NAME, + "model": NAME, + "manufacturer": NAME, + "suggested_area": self.area_name, + } + + @property + def area_name(self) -> str: + """Return area name or fallback.""" + return self.area.name if self.area is not None else "unknown" + + @property + def slugified_area_name(self) -> str: + """Return slugified area name or fallback.""" + return slugify(self.area.name) if self.area is not None else "unknown" diff --git a/homeassistant/components/effortlesshome/auto_entity.py b/homeassistant/components/effortlesshome/auto_entity.py new file mode 100644 index 0000000000000..d9ced8e56e02d --- /dev/null +++ b/homeassistant/components/effortlesshome/auto_entity.py @@ -0,0 +1,166 @@ +"""Base auto-entity class.""" + +import logging +from typing import Generic, TypeVar + +from propcache.api import cached_property + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.cover import CoverDeviceClass +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.typing import StateType + +from .auto_area import AutoArea +from .calculations import get_calculation +from .const import DOMAIN, NAME + +_TEntity = TypeVar("_TEntity", bound=Entity) +_TDeviceClass = TypeVar( + "_TDeviceClass", BinarySensorDeviceClass, SensorDeviceClass, CoverDeviceClass +) + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +class AutoEntity(Entity, Generic[_TEntity, _TDeviceClass]): # pylint: disable=hass-enforce-class-module + """Set up an Auto Area entity.""" + + def __init__( + self, + hass: HomeAssistant, + auto_area: AutoArea, + device_class: _TDeviceClass, + name_prefix: str, + prefix: str, + ) -> None: + """Initialize sensor.""" + super().__init__() + self.hass = hass + self.auto_area = auto_area + self._device_class: _TDeviceClass = device_class + self._name_prefix = name_prefix + self._prefix = prefix + + self.entity_ids: list[str] = self._get_sensor_entities() + self.unsubscribe = None + self.entity_states: dict[str, State] = {} + self._aggregated_state: StateType = None + + _LOGGER.info( + "%s (%s): Initialized sensor. Entities: %s", + self.auto_area.area_name, + self.device_class, + self.entity_ids, + ) + + def _get_sensor_entities(self) -> list[str]: + """Retrieve all relevant entity ids for this sensor.""" + return [ + entity.entity_id + for entity in self.auto_area.get_valid_entities() + if self.device_class + in ( + entity.device_class, + entity.original_device_class, + ) + ] + + @cached_property + def name(self): + """Name of this entity.""" + return f"{self._name_prefix}{self.auto_area.area_name}" + + @cached_property + def unique_id(self) -> str: + """Return a unique ID.""" + return f"{self.auto_area.area_id}_aggregated_{self.device_class}" + + @cached_property + def device_class(self) -> _TDeviceClass: + """Return device class.""" + return self._device_class + + @property + def device_info(self): + """Return information about the device.""" + return { + "identifiers": {(DOMAIN, NAME)}, + "name": NAME, + "manufacturer": NAME, + } + + @cached_property + def suggested_display_precision(self) -> int | None: + """Set the suggested precision (0.12).""" + return 2 + + async def async_added_to_hass(self): + """Start tracking sensors.""" + # Get all current states + for entity_id in self.entity_ids: + state = self.hass.states.get(entity_id) + if state is not None: + try: + self.entity_states[entity_id] = state + except ValueError: + _LOGGER.warning( + "%s (%s): No initial state available for %s", + self.auto_area.area_name, + self.device_class, + entity_id, + ) + + self._aggregated_state = self._get_state() + self.schedule_update_ha_state() + + # Subscribe to state changes + self.unsubscribe = async_track_state_change_event( + self.hass, + self.entity_ids, + self._handle_state_change, + ) + + async def _handle_state_change(self, event: Event[EventStateChangedData]): + """Handle state change of any tracked illuminance sensors.""" + to_state = event.data.get("new_state") + if to_state is None: + return + + if to_state.state in [ + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ]: + self.entity_states.pop(to_state.entity_id, None) + else: + try: + to_state.state = float(to_state.state) # type: ignore[assignment] + self.entity_states[to_state.entity_id] = to_state + except ValueError: + self.entity_states.pop(to_state.entity_id, None) + + self._aggregated_state = self._get_state() + + self.async_schedule_update_ha_state() + + async def async_will_remove_from_hass(self) -> None: + """Clean up event listeners.""" + if self.unsubscribe: + self.unsubscribe() + + def _get_state(self) -> StateType | None: + """Get the state of the sensor.""" + calculate_state = get_calculation(self.device_class) + + if calculate_state is None: + _LOGGER.error( + "%s: %s unable to get state calculation method", + self.auto_area.area_name, + self.device_class, + ) + return None + + return calculate_state(list(self.entity_states.values())) diff --git a/homeassistant/components/effortlesshome/config_flow.py b/homeassistant/components/effortlesshome/config_flow.py new file mode 100644 index 0000000000000..f43df18009fdc --- /dev/null +++ b/homeassistant/components/effortlesshome/config_flow.py @@ -0,0 +1,331 @@ +"""Config flow for EffortlessHome integration.""" + +from collections.abc import Mapping +import logging +from typing import Any + +from oasira import OasiraAPIClient, OasiraAPIError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry, ConfigFlowResult, OptionsFlow +from homeassistant.core import callback + +from .const import DOMAIN, NAME + +_LOGGER = logging.getLogger(__name__) + +CONF_EMAIL = "email" +CONF_PASSWORD = "password" +CONF_SYSTEM_ID = "system_id" +CONF_CUSTOMER_ID = "customer_id" +CONF_FIREBASE_UID = "firebase_uid" +CONF_ID_TOKEN = "id_token" +CONF_REFRESH_TOKEN = "refresh_token" + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle the EffortlessHome integration setup with Firebase OAuth.""" + + VERSION = 2 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._firebase_uid: str | None = None + self._id_token: str | None = None + self._refresh_token: str | None = None + self._email: str | None = None + self._customer_id: str | None = None + self._system_id: str | None = None + self._available_systems: list[dict[str, Any]] = [] + + async def async_step_user( # pylint: disable=too-many-nested-blocks + self, user_input: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step - collect email and password for Firebase auth.""" + errors: dict[str, str] = {} + + if user_input is not None: + email = user_input.get(CONF_EMAIL) + password = user_input.get(CONF_PASSWORD) + + if not email or not password: + errors["base"] = "missing_fields" + else: + try: + auth_result = await self._authenticate_firebase(email, password) + except Exception: + _LOGGER.exception("Unexpected error during authentication") + errors["base"] = "unknown" + else: + if not auth_result: + errors["base"] = "invalid_auth" + else: + self._firebase_uid = auth_result["firebase_uid"] + self._id_token = auth_result["id_token"] + self._refresh_token = auth_result["refresh_token"] + self._email = email + + try: + systems = await self._fetch_system_list(email) + except OasiraAPIError as err: + _LOGGER.error("Failed to fetch system list: %s", err) + errors["base"] = "cannot_connect" + else: + if not systems: + _LOGGER.warning("No systems found for user %s", email) + errors["base"] = "no_system_found" + elif len(systems) == 1: + system = systems[0] + self._customer_id = str(system["customer_id"]) + self._system_id = str(system["SystemID"]) + + await self.async_set_unique_id( + f"{self._customer_id}_{self._system_id}" + ) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=f"{NAME} ({email})", + data={ + CONF_EMAIL: email, + CONF_FIREBASE_UID: self._firebase_uid, + CONF_ID_TOKEN: self._id_token, + CONF_REFRESH_TOKEN: self._refresh_token, + CONF_CUSTOMER_ID: self._customer_id, + CONF_SYSTEM_ID: self._system_id, + }, + ) + else: + self._available_systems = systems + return await self.async_step_select_system() + + data_schema = vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } + ) + + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + description_placeholders={ + "info": "Enter your EffortlessHome account credentials to link your system." + }, + ) + + async def async_step_select_system( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle system selection when user has multiple systems.""" + errors: dict[str, str] = {} + + if user_input is not None: + selected_system_key = user_input.get(CONF_SYSTEM_ID) + + # Find the selected system + selected_system = None + for system in self._available_systems: + system_key = f"{system['customer_id']}_{system['SystemID']}" + if system_key == selected_system_key: + selected_system = system + break + + if selected_system: + self._customer_id = str(selected_system["customer_id"]) + self._system_id = str(selected_system["SystemID"]) + + # Check if already configured + await self.async_set_unique_id(f"{self._customer_id}_{self._system_id}") + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=f"{NAME} ({self._email})", + data={ + CONF_EMAIL: self._email, + CONF_FIREBASE_UID: self._firebase_uid, + CONF_ID_TOKEN: self._id_token, + CONF_REFRESH_TOKEN: self._refresh_token, + CONF_CUSTOMER_ID: self._customer_id, + CONF_SYSTEM_ID: self._system_id, + }, + ) + errors["base"] = "invalid_system" + + # Build system selection options + system_options = {} + for system in self._available_systems: + system_key = f"{system['customer_id']}_{system['SystemID']}" + ha_url = system.get("ha_url", "Unknown") + system_options[system_key] = f"System {system['SystemID']} - {ha_url}" + + data_schema = vol.Schema( + { + vol.Required(CONF_SYSTEM_ID): vol.In(system_options), + } + ) + + return self.async_show_form( + step_id="select_system", + data_schema=data_schema, + errors=errors, + description_placeholders={ + "info": "Multiple systems found. Please select which system to configure." + }, + ) + + async def async_step_manual_entry( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle manual entry of customer_id and system_id if API lookup fails.""" + errors: dict[str, str] = {} + + if user_input is not None: + customer_id = user_input.get(CONF_CUSTOMER_ID) + system_id = user_input.get(CONF_SYSTEM_ID) + + if not customer_id or not system_id: + errors["base"] = "missing_fields" + else: + # Store the manually entered IDs + self._customer_id = customer_id + self._system_id = system_id + + # Check if already configured + await self.async_set_unique_id(f"{self._customer_id}_{self._system_id}") + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=f"{NAME} ({self._email})", + data={ + CONF_EMAIL: self._email, + CONF_FIREBASE_UID: self._firebase_uid, + CONF_ID_TOKEN: self._id_token, + CONF_CUSTOMER_ID: self._customer_id, + CONF_SYSTEM_ID: self._system_id, + }, + ) + + data_schema = vol.Schema( + { + vol.Required(CONF_CUSTOMER_ID): str, + vol.Required(CONF_SYSTEM_ID): str, + } + ) + + return self.async_show_form( + step_id="manual_entry", + data_schema=data_schema, + errors=errors, + description_placeholders={ + "info": "Enter your Customer ID and System ID to complete setup." + }, + ) + + async def _authenticate_firebase( + self, email: str, password: str + ) -> dict[str, Any] | None: + """Authenticate with Firebase and return user credentials. + + Args: + email: User's email + password: User's password + + Returns: + Dictionary with firebase_uid and id_token, or None if authentication fails + """ + try: + async with OasiraAPIClient() as client: + data = await client.firebase_sign_in(email, password) + + # Validate that we got the required tokens + firebase_uid = data.get("localId") + id_token = data.get("idToken") + refresh_token = data.get("refreshToken") + + if not firebase_uid or not id_token: + _LOGGER.error( + "Firebase auth response missing required fields. Response: %s", + data, + ) + return None + + return { + "firebase_uid": firebase_uid, + "id_token": id_token, + "refresh_token": refresh_token, + } + except OasiraAPIError as err: + _LOGGER.error("Firebase auth error: %s", err) + return None + except Exception: + _LOGGER.exception("Error calling Firebase auth API") + return None + + async def _fetch_system_list(self, email: str) -> list[dict[str, Any]]: + """Fetch list of systems for the user from EffortlessHome API. + + Args: + email: User's email address + + Returns: + List of system dictionaries, each containing SystemID, customer_id, ha_url, etc. + """ + _LOGGER.debug("Fetching system list for email %s", email) + + if not self._id_token: + _LOGGER.error("Cannot fetch system list: id_token is not set") + raise OasiraAPIError("Authentication token is missing") + + try: + async with OasiraAPIClient(id_token=self._id_token) as client: + systems = await client.get_system_list_by_email(email) + _LOGGER.info("Found %d system(s) for email %s", len(systems), email) + return systems + except OasiraAPIError: + _LOGGER.exception("Failed to fetch system list") + raise + except Exception as err: + _LOGGER.exception("Unexpected error fetching system list") + raise OasiraAPIError(f"Unexpected error: {err}") from err + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth - re-enter credentials.""" + return await self.async_step_user(entry_data) + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle options flow for EffortlessHome.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self._config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + "debug_mode", + default=self._config_entry.options.get("debug_mode", False), + ): bool, + } + ), + ) diff --git a/homeassistant/components/effortlesshome/const.py b/homeassistant/components/effortlesshome/const.py new file mode 100644 index 0000000000000..0b9613d04c418 --- /dev/null +++ b/homeassistant/components/effortlesshome/const.py @@ -0,0 +1,27 @@ +"""Constants for the simplified EffortlessHome integration.""" + +VERSION = "1.1.25" +NAME = "EffortlessHome" +DOMAIN = "effortlesshome" + +LABELS = [ + "Favorite", + "NotForSecurityMonitoring", +] + +RELEVANT_DOMAINS = ( + "binary_sensor", + "camera", + "climate", + "cover", + "fan", + "humidifier", + "light", + "lock", + "media_player", + "remote", + "sensor", + "switch", + "vacuum", + "water_heater", +) diff --git a/homeassistant/components/effortlesshome/deviceclassgroupsync.py b/homeassistant/components/effortlesshome/deviceclassgroupsync.py new file mode 100644 index 0000000000000..4554967c1f0aa --- /dev/null +++ b/homeassistant/components/effortlesshome/deviceclassgroupsync.py @@ -0,0 +1,94 @@ +"""Sync entities into helper groups by device class.""" + +import logging + +from homeassistant.components.group import DOMAIN as GROUP_DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class DeviceClassGroupSync: + """Custom integration class to sync devices by device_class into a group.""" + + def __init__(self, hass, group_name, device_class) -> None: + """Initialize the class with the Home Assistant instance.""" + self.hass = hass + self.group_name = group_name + self.device_class = device_class + + async def find_and_sync_devices(self) -> None: + """Find all devices by device_class and sync them into a group.""" + # Get all states from Home Assistant + all_entities = self.hass.states.async_all() + + # Filter entities by device_class + matching_entities = [ + entity.entity_id + for entity in all_entities + if ( + entity.attributes.get("device_class") == self.device_class + and "group_sensor" not in entity.entity_id + ) + ] + + # Use the group.set service to create or update the group + await self.hass.services.async_call( + GROUP_DOMAIN, + "set", + { + "object_id": self.group_name, + "name": f"{self.device_class} Group", + "entities": matching_entities, + }, + ) + + _LOGGER.debug( + f"Synced {len(matching_entities)} entities to group {self.group_name}" # noqa: G004 + ) + + +# Example usage inside your custom integration +async def async_setup_devicegroup(hass) -> bool: + """Set up the integration.""" + + # Initialize the group sync for 'smoke' device_class + smokealarm_sync = DeviceClassGroupSync(hass, "smokealarm_sensors_group", "smoke") + await smokealarm_sync.find_and_sync_devices() + + # Initialize the group sync for 'carbon_monoxide' device_class + carbon_monoxide_sync = DeviceClassGroupSync( + hass, "carbon_monoxide_sensors_group", "carbon_monoxide" + ) + await carbon_monoxide_sync.find_and_sync_devices() + + # Initialize the group sync for 'door' device_class + door_sync = DeviceClassGroupSync(hass, "door_sensors_group", "door") + await door_sync.find_and_sync_devices() + + # Initialize the group sync for 'window' device_class + window_sync = DeviceClassGroupSync(hass, "window_sensors_group", "window") + await window_sync.find_and_sync_devices() + + # Initialize the group sync for 'moisture' device_class + moisture_sync = DeviceClassGroupSync(hass, "moisture_sensors_group", "moisture") + await moisture_sync.find_and_sync_devices() + + # Initialize the group sync for 'sound' device_class + sound_sync = DeviceClassGroupSync(hass, "sound_sensors_group", "sound") + await sound_sync.find_and_sync_devices() + + # Initialize the group sync for 'vibration' device_class + vibration_sync = DeviceClassGroupSync(hass, "vibration_sensors_group", "vibration") + await vibration_sync.find_and_sync_devices() + + # Initialize the group sync for 'humidity' device_class + humidity_sync = DeviceClassGroupSync(hass, "humidity_sensors_group", "humidity") + await humidity_sync.find_and_sync_devices() + + # Initialize the group sync for 'temperature' device_class + temperature_sync = DeviceClassGroupSync( + hass, "temperature_sensors_group", "temperature" + ) + await temperature_sync.find_and_sync_devices() + + return True diff --git a/homeassistant/components/effortlesshome/icons.json b/homeassistant/components/effortlesshome/icons.json new file mode 100644 index 0000000000000..395552c782936 --- /dev/null +++ b/homeassistant/components/effortlesshome/icons.json @@ -0,0 +1,22 @@ +{ + "services": { + "add_label_to_entity": { + "service": "mdi:tag-plus" + }, + "clean_motion_files": { + "service": "mdi:delete-sweep" + }, + "create_alert": { + "service": "mdi:alert-plus" + }, + "create_event": { + "service": "mdi:calendar-plus" + }, + "deploy_latest_config": { + "service": "mdi:cloud-upload" + }, + "update_entity": { + "service": "mdi:pencil" + } + } +} diff --git a/homeassistant/components/effortlesshome/manifest.json b/homeassistant/components/effortlesshome/manifest.json new file mode 100644 index 0000000000000..561681ffdb238 --- /dev/null +++ b/homeassistant/components/effortlesshome/manifest.json @@ -0,0 +1,22 @@ +{ + "domain": "effortlesshome", + "name": "EffortlessHome", + "codeowners": [ + "@effortlesshome" + ], + "config_flow": true, + "dependencies": [ + "http", + "recorder" + ], + "documentation": "https://www.home-assistant.io/integrations/effortlesshome", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": [ + "effortlesshome" + ], + "quality_scale": "bronze", + "requirements": [ + "oasira==0.2.12" + ] +} \ No newline at end of file diff --git a/homeassistant/components/effortlesshome/medication_tracking.py b/homeassistant/components/effortlesshome/medication_tracking.py new file mode 100644 index 0000000000000..05fa1528ca781 --- /dev/null +++ b/homeassistant/components/effortlesshome/medication_tracking.py @@ -0,0 +1,67 @@ +"""Medication tracking switch entities.""" + +from __future__ import annotations + +import logging + +from propcache.api import cached_property + +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import UndefinedType + +from .const import DOMAIN, NAME + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +class MedicationTrackingSwitch(SwitchEntity, RestoreEntity): # pylint: disable=hass-enforce-class-module + """Set up a medication tracking switch.""" + + _attr_should_poll = False + + def __init__(self, name) -> None: + """Initialize switch.""" + self._is_on: bool = False + self._attr_name = name + + @cached_property + def name(self) -> str | UndefinedType | None: + """Return the name of the entity.""" + return self._attr_name + + @property + def device_info(self): + """Return information about the device.""" + return { + "identifiers": {(DOMAIN, NAME)}, + "name": NAME, + "manufacturer": NAME, + } + + @cached_property + def device_class(self) -> SwitchDeviceClass | None: + """Return device class.""" + return SwitchDeviceClass.SWITCH + + @property + def is_on(self) -> bool | None: + """Return the state of the switch.""" + return self._is_on + + def turn_on(self, **kwargs) -> None: + """Turn on switch.""" + self._is_on = True + self.schedule_update_ha_state() + + def turn_off(self, **kwargs): + """Turn off switch.""" + self._is_on = False + self.schedule_update_ha_state() + + async def async_added_to_hass(self): + """Restore previous state when entity is added.""" + await super().async_added_to_hass() + + if (last_state := await self.async_get_last_state()) is not None: + self._is_on = last_state.state == "on" diff --git a/homeassistant/components/effortlesshome/motion_notification.py b/homeassistant/components/effortlesshome/motion_notification.py new file mode 100644 index 0000000000000..d260fc1a98b51 --- /dev/null +++ b/homeassistant/components/effortlesshome/motion_notification.py @@ -0,0 +1,68 @@ +"""Motion notification switch entity.""" + +from __future__ import annotations + +import logging + +from propcache.api import cached_property + +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.helpers.typing import UndefinedType + +from .const import DOMAIN, NAME + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +class MotionNotificationsSwitch(SwitchEntity): # pylint: disable=hass-enforce-class-module + """Set up a motion notifications switch.""" + + _attr_should_poll = False + + def __init__(self) -> None: + """Initialize motion notifications switch.""" + + self._is_on: bool = True + + @cached_property + def name(self) -> str | UndefinedType | None: + """Return the name of the entity.""" + return "Motion Notifications" + + @property + def unique_id(self) -> str: + """Return the unique ID of the sensor.""" + return "motion_notifications" + + @property + def device_info(self): + """Return information about the device.""" + return { + "identifiers": {(DOMAIN, NAME)}, + "name": NAME, + "manufacturer": NAME, + } + + @cached_property + def device_class(self) -> SwitchDeviceClass | None: + """Return device class.""" + return SwitchDeviceClass.SWITCH + + @property + def is_on(self) -> bool | None: + """Return the state of the switch.""" + return self._is_on + + def turn_on(self, **kwargs) -> None: + """Turn on switch.""" + self._is_on = True + self.schedule_update_ha_state() + + def turn_off(self, **kwargs): + """Turn off switch.""" + self._is_on = False + self.schedule_update_ha_state() + + async def async_added_to_hass(self): + """Restore previous state when entity is added.""" + await super().async_added_to_hass() diff --git a/homeassistant/components/effortlesshome/quality_scale.yaml b/homeassistant/components/effortlesshome/quality_scale.yaml new file mode 100644 index 0000000000000..aabe11a3009ad --- /dev/null +++ b/homeassistant/components/effortlesshome/quality_scale.yaml @@ -0,0 +1,64 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not provide custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not subscribe entities to events directly. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/effortlesshome/run_core_pr_checks.ps1 b/homeassistant/components/effortlesshome/run_core_pr_checks.ps1 new file mode 100644 index 0000000000000..925b046018c99 --- /dev/null +++ b/homeassistant/components/effortlesshome/run_core_pr_checks.ps1 @@ -0,0 +1,254 @@ +[CmdletBinding()] +param( + [ValidateSet("all", "prek", "hassfest", "pylint", "mypy")] + [string[]]$Checks = @("all"), + + [string]$Integration = "effortlesshome", + + [switch]$Setup +) + +$ErrorActionPreference = "Stop" + +function Require-Command { + param([Parameter(Mandatory = $true)][string]$Name) + + $command = Get-Command $Name -ErrorAction SilentlyContinue + if (-not $command) { + throw "Required command '$Name' is not available on PATH." + } + + return $command.Path +} + +function Find-PythonCommand { + foreach ($candidate in @("py", "python", "python3")) { + $command = Get-Command $candidate -ErrorAction SilentlyContinue + if ($command) { + return $command.Path + } + } + + throw "No Python command found on PATH. Install Python 3.13 and retry." +} + +# Setup steps: abort immediately on failure. +function Invoke-SetupStep { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][scriptblock]$Action + ) + + Write-Host "" + Write-Host "========== $Name ==========" + & $Action + if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) { + throw "Setup step '$Name' failed with exit code $LASTEXITCODE." + } +} + +# Check steps: always run to completion; failures are collected and reported at the end. +$script:checkResults = [System.Collections.Generic.List[object]]::new() + +function Invoke-CheckStep { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][scriptblock]$Action + ) + + Write-Host "" + Write-Host "========== $Name ==========" + & $Action + $code = $LASTEXITCODE + if ($code -and $code -ne 0) { + $script:checkResults.Add([pscustomobject]@{ Name = $Name; Passed = $false; ExitCode = $code }) + } + else { + $script:checkResults.Add([pscustomobject]@{ Name = $Name; Passed = $true; ExitCode = 0 }) + } +} + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$coreRoot = Resolve-Path (Join-Path $scriptDir "..\..\..") + +Set-Location $coreRoot + +$componentPath = "homeassistant/components/$Integration" +if (-not (Test-Path $componentPath)) { + throw "Integration path '$componentPath' does not exist under $coreRoot" +} + +$venvPython = Join-Path $coreRoot ".venv\Scripts\python.exe" +$venvScripts = Join-Path $coreRoot ".venv\Scripts" +$prekExe = Join-Path $venvScripts "prek.exe" +$pylintExe = Join-Path $venvScripts "pylint.exe" +$mypyExe = Join-Path $venvScripts "mypy.exe" + +if ($Checks -contains "all") { + $checksToRun = @("prek", "hassfest", "pylint", "mypy") +} +else { + $checksToRun = $Checks +} + +$needsSetup = $Setup -or -not (Test-Path $venvPython) +if (-not $needsSetup) { + if (($checksToRun -contains "prek") -and -not (Test-Path $prekExe)) { + $needsSetup = $true + } + if (($checksToRun -contains "pylint") -and -not (Test-Path $pylintExe)) { + $needsSetup = $true + } + if (($checksToRun -contains "mypy") -and -not (Test-Path $mypyExe)) { + $needsSetup = $true + } +} + +# Prevent git from trying to hash .agent/skills (incompatible file on Windows) +# that causes `git diff` to fail with "Function not implemented" inside prek. +$agentSkills = Join-Path (Join-Path $coreRoot ".agent") "skills" +if (Test-Path $agentSkills) { + git -C $coreRoot update-index --assume-unchanged ".agent/skills" 2>$null +} + +if ($needsSetup) { + $uvCommand = Get-Command "uv" -ErrorAction SilentlyContinue + $uvPath = $null + if ($uvCommand) { + $uvPath = $uvCommand.Path + } + $pythonBootstrap = Find-PythonCommand + + if (-not (Test-Path $venvPython)) { + Invoke-SetupStep -Name "Create .venv" -Action { + if ($uvPath) { + & $uvPath venv .venv + } + else { + & $pythonBootstrap -m venv .venv + } + } + } + + # Install HA core + its runtime dependencies (brings in orjson etc.) + Invoke-SetupStep -Name "Install HA package (editable)" -Action { + if ($uvPath) { + & $uvPath pip install --python $venvPython -e . --config-settings editable_mode=compat + } + else { + & $venvPython -m pip install --upgrade pip + & $venvPython -m pip install -e . --config-settings editable_mode=compat + } + } + + # Install base HA runtime requirements (includes orjson, needed by translations/hassfest) + Invoke-SetupStep -Name "Install requirements.txt" -Action { + if ($uvPath) { + & $uvPath pip install --python $venvPython -r requirements.txt + } + else { + & $venvPython -m pip install -r requirements.txt + } + } + + # Install linting tools: prek, pylint, mypy, and typed stubs + Invoke-SetupStep -Name "Install requirements_test.txt (lint tools)" -Action { + if ($uvPath) { + & $uvPath pip install --python $venvPython -r requirements_test.txt + } + else { + & $venvPython -m pip install -r requirements_test.txt + } + } +} + +if (-not (Test-Path $venvPython)) { + throw "Missing .venv python at $venvPython after setup." +} + +# Make .venv tools resolvable for subprocesses (hassfest calls `ruff`). +if (-not ($env:PATH -split ';' | Where-Object { $_ -eq $venvScripts })) { + $env:PATH = "$venvScripts;$env:PATH" +} + +foreach ($check in $checksToRun) { + switch ($check) { + "prek" { + if (-not (Test-Path $prekExe)) { + throw "prek is not installed in .venv after setup." + } + + Invoke-CheckStep -Name "Run prek checks" -Action { + # Re-apply assume-unchanged in case git index was refreshed. + git -C $coreRoot update-index --assume-unchanged ".agent/skills" 2>$null + + # Use --files to target only integration files. + # Avoids --all-files which runs git ls-files and tries to hash + # .agent/skills (incompatible on Windows). + $rootLen = ([string]$coreRoot).Length + 1 + $integrationFiles = Get-ChildItem -Path $componentPath -Recurse -File | + Where-Object { $_.Extension -in @('.py', '.yaml', '.json', '.md', '.js') } | + ForEach-Object { $_.FullName.Substring($rootLen).Replace('\', '/') } + + if (-not $integrationFiles) { + Write-Host "No checkable files found in $componentPath - skipping prek." + return + } + + $env:PREK_SKIP = "no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor" + & $prekExe run --files @integrationFiles + Remove-Item Env:PREK_SKIP -ErrorAction SilentlyContinue + } + } + + "hassfest" { + Invoke-CheckStep -Name "Check hassfest" -Action { + & $venvPython -m script.hassfest --requirements --action validate --integration-path $componentPath + } + } + + "pylint" { + if (-not (Test-Path $pylintExe)) { + throw "pylint is not installed in .venv after setup." + } + + Invoke-CheckStep -Name "Check pylint" -Action { + & $pylintExe --ignore-missing-annotations=y $componentPath + } + } + + "mypy" { + if (-not (Test-Path $mypyExe)) { + throw "mypy is not installed in .venv after setup." + } + + Invoke-CheckStep -Name "Check mypy" -Action { + & $mypyExe $componentPath + } + } + } +} + +Write-Host "" +Write-Host "============================================================" +Write-Host "RESULTS" +Write-Host "============================================================" +$anyFailed = $false +foreach ($r in $script:checkResults) { + if ($r.Passed) { + Write-Host " PASSED $($r.Name)" + } + else { + Write-Host " FAILED $($r.Name) (exit $($r.ExitCode))" + $anyFailed = $true + } +} +Write-Host "" +if ($anyFailed) { + Write-Host "One or more checks failed." + exit 1 +} +else { + Write-Host "All checks passed." + exit 0 +} \ No newline at end of file diff --git a/homeassistant/components/effortlesshome/services.yaml b/homeassistant/components/effortlesshome/services.yaml new file mode 100644 index 0000000000000..e486d27d380e9 --- /dev/null +++ b/homeassistant/components/effortlesshome/services.yaml @@ -0,0 +1,82 @@ +# Service to clean motion files older than specified age +clean_motion_files: + target: {} + fields: + age: + example: 30 + selector: + number: + min: 1 + max: 365 + unit_of_measurement: days + +# Add a label to an entity +add_label_to_entity: + target: {} + fields: + entity_id: + example: "sensor.living_room_temperature" + required: true + selector: + entity: {} + + label: + example: "favorite" + required: true + selector: + label: {} + +# Create an alert record +create_alert: + target: {} + fields: + alert_type: + example: "Humidity Alert" + required: true + selector: + text: {} + alert_description: + example: "High humidity detected in basement" + required: true + selector: + text: + multiline: true + status: + example: "Active" + required: true + selector: + select: + options: + - "Active" + - "Resolved" + - "Acknowledged" + +# Update entity area +update_entity: + target: {} + fields: + entity_id: + example: "sensor.living_room_temperature" + required: true + selector: + entity: {} + + area_id: + example: "living_room" + required: true + selector: + area: {} + +# Deploy latest EffortlessHome config +deploy_latest_config: + target: {} + +# Create event service +create_event: + target: {} + fields: + entity_id: + example: "sensor.living_room_temperature" + required: true + selector: + entity: {} diff --git a/homeassistant/components/effortlesshome/sleep_mode.py b/homeassistant/components/effortlesshome/sleep_mode.py new file mode 100644 index 0000000000000..d767997a398e3 --- /dev/null +++ b/homeassistant/components/effortlesshome/sleep_mode.py @@ -0,0 +1,74 @@ +"""Sleep mode switch.""" + +from __future__ import annotations + +import logging + +from propcache.api import cached_property + +from homeassistant.components.switch import SwitchEntity +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import UndefinedType + +from .const import DOMAIN, NAME + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +class SleepModeSwitch(SwitchEntity, RestoreEntity): # pylint: disable=hass-enforce-class-module + """Set up a sleep mode switch.""" + + _attr_should_poll = False + + def __init__(self) -> None: + """Initialize sleep mode switch.""" + self._is_on: bool = False + + @cached_property + def name(self) -> str | UndefinedType | None: + """Return the name of the entity.""" + return "Sleep Mode" + + @property + def unique_id(self) -> str: + """Return the unique ID of the sensor.""" + return "sleep_mode" + + @property + def device_info(self): + """Return information about the device.""" + return { + "identifiers": {(DOMAIN, NAME)}, + "name": NAME, + "manufacturer": NAME, + } + + @property + def is_on(self) -> bool | None: + """Return the state of the switch.""" + return self._is_on + + def turn_on(self, **kwargs) -> None: + """Turn on switch.""" + + self._is_on = True + self.schedule_update_ha_state() + self.hass.add_job( + self.hass.bus.async_fire, "sleeping_switch_updated", {"is_on": self._is_on} + ) + + def turn_off(self, **kwargs): + """Turn off switch.""" + + self._is_on = False + self.schedule_update_ha_state() + self.hass.add_job( + self.hass.bus.async_fire, "sleeping_switch_updated", {"is_on": self._is_on} + ) + + async def async_added_to_hass(self): + """Restore previous state when entity is added.""" + await super().async_added_to_hass() + + if (last_state := await self.async_get_last_state()) is not None: + self._is_on = last_state.state == "on" diff --git a/homeassistant/components/effortlesshome/smart_appliance_conversion.py b/homeassistant/components/effortlesshome/smart_appliance_conversion.py new file mode 100644 index 0000000000000..fa60f66cd6eed --- /dev/null +++ b/homeassistant/components/effortlesshome/smart_appliance_conversion.py @@ -0,0 +1,73 @@ +"""Smart Appliance Conversion module for EffortlessHome.""" + +from __future__ import annotations + +import logging + +from propcache.api import cached_property + +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import UndefinedType + +from .const import DOMAIN, NAME + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +class SmartApplianceConversionSwitch(SwitchEntity, RestoreEntity): # pylint: disable=hass-enforce-class-module + """Set up a SmartApplianceConversionSwitch.""" + + _attr_should_poll = False + + def __init__(self, name) -> None: + """Initialize SmartApplianceConversionSwitch.""" + + self._is_on: bool = False + self._attr_name = name + + @property + def device_info(self): + """Return information about the device.""" + return { + "identifiers": {(DOMAIN, NAME)}, + "name": NAME, + "manufacturer": NAME, + } + + @cached_property + def name(self) -> str | UndefinedType | None: + """Return the name of the entity.""" + return self._attr_name + + @cached_property + def device_class(self) -> SwitchDeviceClass | None: + """Return device class.""" + return SwitchDeviceClass.SWITCH + + @cached_property + def unique_id(self) -> str | None: + """Return a unique ID.""" + return f"{self._attr_name}_switch" + + @property + def is_on(self) -> bool | None: + """Return the state of the switch.""" + return self._is_on + + def turn_on(self, **kwargs) -> None: + """Turn on switch.""" + self._is_on = True + self.schedule_update_ha_state() + + def turn_off(self, **kwargs): + """Turn off switch.""" + self._is_on = False + self.schedule_update_ha_state() + + async def async_added_to_hass(self): + """Restore previous state when entity is added.""" + await super().async_added_to_hass() + + if (last_state := await self.async_get_last_state()) is not None: + self._is_on = last_state.state == "on" diff --git a/homeassistant/components/effortlesshome/strings.json b/homeassistant/components/effortlesshome/strings.json new file mode 100644 index 0000000000000..521942809b440 --- /dev/null +++ b/homeassistant/components/effortlesshome/strings.json @@ -0,0 +1,116 @@ +{ + "config": { + "abort": { + "already_configured": "This EffortlessHome system is already configured.", + "reauth_successful": "Re-authentication successful" + }, + "error": { + "cannot_connect": "Unable to connect to EffortlessHome services. Please check your internet connection.", + "invalid_auth": "Invalid email or password. Please check your credentials and try again.", + "invalid_system": "Invalid system selection. Please try again.", + "missing_fields": "Please provide all required fields", + "no_system_found": "No system found for this account. Please ensure your account is properly set up on the EffortlessHome website", + "unknown": "An unexpected error occurred. Please try again later." + }, + "step": { + "manual_entry": { + "data": { + "customer_id": "Customer ID", + "info": "Information", + "system_id": "System ID" + }, + "description": "Enter your Customer ID and System ID manually", + "title": "Manual Configuration" + }, + "select_system": { + "data": { + "system_id": "System" + }, + "description": "Multiple systems found for your account. Please select which system to configure.", + "title": "Select System" + }, + "user": { + "data": { + "email": "Email", + "info": "Information", + "password": "Password" + }, + "description": "Enter your EffortlessHome account credentials. New users can sign up on the EffortlessHome website.", + "title": "Sign in to EffortlessHome" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "debug_mode": "Enable debug logging" + }, + "title": "EffortlessHome Options" + } + } + }, + "services": { + "add_label_to_entity": { + "description": "Add a label to an entity", + "fields": { + "entity_id": { + "name": "Entity" + }, + "label": { + "name": "Label" + } + }, + "name": "Add label to entity" + }, + "clean_motion_files": { + "description": "Clean motion files older than specified number of days", + "fields": { + "age": { + "name": "Age" + } + }, + "name": "Clean motion files" + }, + "create_alert": { + "description": "Create an alert record", + "fields": { + "alert_description": { + "name": "Alert description" + }, + "alert_type": { + "name": "Alert type" + }, + "status": { + "name": "Status" + } + }, + "name": "Create alert" + }, + "create_event": { + "description": "Create an event for the active alarm", + "fields": { + "entity_id": { + "name": "Entity" + } + }, + "name": "Create event" + }, + "deploy_latest_config": { + "description": "Deploy latest EffortlessHome configuration files (themes, blueprints, cards)", + "name": "Deploy latest config" + }, + "update_entity": { + "description": "Update an entity in the entity registry with an area", + "fields": { + "area_id": { + "name": "Area" + }, + "entity_id": { + "name": "Entity" + } + }, + "name": "Update entity" + } + } +} diff --git a/homeassistant/components/effortlesshome/switch.py b/homeassistant/components/effortlesshome/switch.py new file mode 100644 index 0000000000000..d229bbadaaf0a --- /dev/null +++ b/homeassistant/components/effortlesshome/switch.py @@ -0,0 +1,287 @@ +"""Switch platform for integration_blueprint.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from propcache.api import cached_property + +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import UndefinedType + +from .const import DOMAIN, NAME +from .motion_notification import MotionNotificationsSwitch +from .sleep_mode import SleepModeSwitch +from .smart_appliance_conversion import SmartApplianceConversionSwitch + +SCAN_INTERVAL = timedelta(seconds=5) +PARALLEL_UPDATES = 0 + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up entities.""" + + async_add_entities( + [ + # PresenceLockSwitch(AutoArea(hass=hass, areaid="unknown")), + SleepModeSwitch(), + MotionNotificationsSwitch(), + MonitoringAlarmSwitch("monitoringalarm"), + DisableMotionLightingSwitch(), + SmartApplianceConversionSwitch("SmartAppliance1"), + SmartApplianceConversionSwitch("SmartAppliance2"), + SmartApplianceConversionSwitch("SmartAppliance3"), + ] + ) + + async_add_entities([PresenceSimulationSwitch(hass)]) + + +class MedicalAlertAlarmSwitch(SwitchEntity, RestoreEntity): + """Set up a medical alert alarm switch.""" + + _attr_should_poll = False + + def __init__(self, name) -> None: + """Initialize switch.""" + self._is_on: bool = False + self._attr_name = name + self.entity_id = "switch." + name + + @property + def device_info(self) -> DeviceInfo | None: + """Return information about the device.""" + return { + "identifiers": {(DOMAIN, NAME)}, + "name": NAME, + "manufacturer": NAME, + } + + @cached_property + def name(self) -> str | UndefinedType | None: + """Return the name of the entity.""" + return self._attr_name + + @cached_property + def device_class(self) -> SwitchDeviceClass | None: + """Return device class.""" + return SwitchDeviceClass.SWITCH + + @property + def is_on(self) -> bool | None: + """Return the state of the switch.""" + return self._is_on + + def turn_on(self, **kwargs: Any) -> None: + """Turn on switch.""" + self._is_on = True + self.schedule_update_ha_state() + self.hass.add_job( + self.hass.bus.async_fire, + "medical_alert_switch_updated", + {"is_on": self._is_on}, + ) + + def turn_off(self, **kwargs: Any) -> None: + """Turn off switch.""" + self._is_on = False + self.schedule_update_ha_state() + self.hass.add_job( + self.hass.bus.async_fire, + "medical_alert_switch_updated", + {"is_on": self._is_on}, + ) + + async def async_added_to_hass(self) -> None: + """Restore previous state when entity is added.""" + await super().async_added_to_hass() + + if (last_state := await self.async_get_last_state()) is not None: + self._is_on = last_state.state == "on" + + +class MonitoringAlarmSwitch(SwitchEntity, RestoreEntity): + """Set up a monitoring alert alarm switch.""" + + _attr_should_poll = False + + def __init__(self, name) -> None: + """Initialize switch.""" + self._is_on: bool = False + self._attr_name = name + + @property + def device_info(self) -> DeviceInfo | None: + """Return information about the device.""" + return { + "identifiers": {(DOMAIN, NAME)}, + "name": NAME, + "manufacturer": NAME, + } + + @cached_property + def name(self) -> str | UndefinedType | None: + """Return the name of the entity.""" + return self._attr_name + + @property + def unique_id(self) -> str: + """Return the unique ID of the sensor.""" + return "monitoring_alarm" + + @cached_property + def device_class(self) -> SwitchDeviceClass | None: + """Return device class.""" + return SwitchDeviceClass.SWITCH + + @property + def is_on(self) -> bool | None: + """Return the state of the switch.""" + return self._is_on + + def turn_on(self, **kwargs: Any) -> None: + """Turn on switch.""" + self._is_on = True + self.schedule_update_ha_state() + self.hass.add_job( + self.hass.bus.async_fire, + "monitoring_alarm_switch_updated", + {"is_on": self._is_on}, + ) + + def turn_off(self, **kwargs: Any) -> None: + """Turn off switch.""" + self._is_on = False + self.schedule_update_ha_state() + self.hass.add_job( + self.hass.bus.async_fire, + "monitoring_alarm_switch_updated", + {"is_on": self._is_on}, + ) + + async def async_added_to_hass(self) -> None: + """Restore previous state when entity is added.""" + await super().async_added_to_hass() + + if (last_state := await self.async_get_last_state()) is not None: + self._is_on = last_state.state == "on" + + +class PresenceSimulationSwitch(SwitchEntity, RestoreEntity): + """Set up a presence simulation switch.""" + + _attr_should_poll = False + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize switch.""" + self._is_on: bool = False + self.hass = hass + self._attr_name = "Presence Simulation" + self.entity_id = "switch.presence_simulation" + + @property + def device_info(self) -> DeviceInfo | None: + """Return information about the device.""" + return { + "identifiers": {(DOMAIN, NAME)}, + "name": NAME, + "manufacturer": NAME, + } + + @cached_property + def name(self) -> str | UndefinedType | None: + """Return the name of the entity.""" + return self._attr_name + + @cached_property + def device_class(self) -> SwitchDeviceClass | None: + """Return device class.""" + return SwitchDeviceClass.SWITCH + + @property + def is_on(self) -> bool | None: + """Return the state of the switch.""" + return self._is_on + + def turn_on(self, **kwargs: Any) -> None: + """Turn on switch.""" + self._is_on = True + self.schedule_update_ha_state() + + def turn_off(self, **kwargs: Any) -> None: + """Turn off switch.""" + self._is_on = False + self.schedule_update_ha_state() + + async def async_added_to_hass(self) -> None: + """Restore previous state when entity is added.""" + await super().async_added_to_hass() + + if (last_state := await self.async_get_last_state()) is not None: + self._is_on = last_state.state == "on" + + +class DisableMotionLightingSwitch(SwitchEntity, RestoreEntity): + """Set up a motion lighting switch.""" + + _attr_should_poll = False + + def __init__(self) -> None: + """Initialize switch.""" + self._is_on: bool = False + self._attr_name = "Disable Motion Lighting" + self.entity_id = "switch.motion_lighting_disable" + + @property + def device_info(self) -> DeviceInfo | None: + """Return information about the device.""" + return { + "identifiers": {(DOMAIN, NAME)}, + "name": NAME, + "manufacturer": NAME, + } + + @cached_property + def name(self) -> str | UndefinedType | None: + """Return the name of the entity.""" + return self._attr_name + + @cached_property + def device_class(self) -> SwitchDeviceClass | None: + """Return device class.""" + return SwitchDeviceClass.SWITCH + + @property + def is_on(self) -> bool | None: + """Return the state of the switch.""" + return self._is_on + + def turn_on(self, **kwargs: Any) -> None: + """Turn on switch.""" + self._is_on = True + self.schedule_update_ha_state() + + def turn_off(self, **kwargs: Any) -> None: + """Turn off switch.""" + self._is_on = False + self.schedule_update_ha_state() + + async def async_added_to_hass(self) -> None: + """Restore previous state when entity is added.""" + await super().async_added_to_hass() + + if (last_state := await self.async_get_last_state()) is not None: + self._is_on = last_state.state == "on" diff --git a/homeassistant/components/effortlesshome/www/effortlesshome/area-panel.js b/homeassistant/components/effortlesshome/www/effortlesshome/area-panel.js new file mode 100644 index 0000000000000..d08a7542d7a59 --- /dev/null +++ b/homeassistant/components/effortlesshome/www/effortlesshome/area-panel.js @@ -0,0 +1,316 @@ +import { + html, + css, + LitElement, +} from "https://cdn.jsdelivr.net/gh/lit/dist@2/core/lit-core.min.js"; +import "https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"; + +class AreaPanel extends LitElement { + static properties = { + hass: {}, + areas: { type: Array }, + areaEntityMap: { type: Object }, + domains: { type: Array }, + selectedDomain: { type: String }, + }; + + static styles = css` + :host { + display: block; + background-color: var( + --lovelace-background, + var(--primary-background-color) + ); + color: var(--primary-text-color); + font-family: var(--paper-font-body1_-_font-family, "Arial", sans-serif); + transition: + background-color 0.3s, + color 0.3s; + min-height: 100vh; + } + + .top-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 20px; + background: var(--card-background-color); + border-bottom: 1px solid var(--divider-color); + box-shadow: var(--ha-card-box-shadow, 0 2px 4px rgba(0, 0, 0, 0.1)); + border-radius: var(--ha-card-border-radius, 12px); + margin: 1rem; + } + + .back-arrow { + font-size: 24px; + color: var(--primary-color); + cursor: pointer; + user-select: none; + transition: color 0.3s; + } + + .back-arrow:hover { + color: var(--accent-color); + } + + .domain-select { + padding: 6px 10px; + font-size: 14px; + border-radius: 6px; + border: 1px solid var(--divider-color); + background-color: var(--secondary-background-color); + color: var(--primary-text-color); + transition: + border-color 0.3s, + background-color 0.3s; + } + + .domain-select:focus { + outline: none; + border-color: var(--primary-color); + } + + .container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 20px; + padding: 20px; + } + + .area-box { + background-color: var(--card-background-color); + border-radius: var(--ha-card-border-radius, 12px); + padding: 16px; + display: flex; + flex-direction: column; + box-shadow: var(--ha-card-box-shadow, 0 2px 6px rgba(0, 0, 0, 0.15)); + transition: + background-color 0.3s, + box-shadow 0.3s; + } + + .area-box h3 { + text-align: center; + margin: 0 0 12px; + font-size: 1.1rem; + font-weight: 600; + color: var(--primary-text-color); + } + + .tile-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; + min-height: 50px; + } + + .entity-tile { + background-color: var(--secondary-background-color); + color: var(--primary-text-color); + padding: 10px; + border-radius: 8px; + box-shadow: var(--ha-card-box-shadow, 0 1px 3px rgba(0, 0, 0, 0.1)); + cursor: grab; + text-align: center; + transition: + transform 0.2s, + background-color 0.3s; + } + + .instructions { + background: var(--card-background-color); + border: 1px solid var(--divider-color); + border-left: 4px solid var(--primary-color); + border-radius: 8px; + padding: 12px 16px; + margin: 1rem; + display: flex; + align-items: center; + gap: 12px; + color: var(--secondary-text-color); + font-size: 0.95rem; + box-shadow: var(--ha-card-box-shadow, 0 1px 3px rgba(0, 0, 0, 0.1)); + } + + .instructions ha-icon { + color: var(--primary-color); + flex-shrink: 0; + } + + .instructions strong { + color: var(--primary-text-color); + } + + .entity-tile:hover { + background-color: var(--accent-color); + color: var(--text-primary-color, #fff); + transform: translateY(-2px); + } + + .entity-tile:active { + cursor: grabbing; + transform: scale(0.98); + } + `; + + constructor() { + super(); + this.areas = []; + this.areaEntityMap = {}; + this.domains = []; + this.selectedDomain = ""; + } + + firstUpdated() { + this._buildAreaTiles(); + } + + updated(changedProps) { + if (changedProps.has("hass")) { + this._buildAreaTiles(); + } + } + + async _buildAreaTiles() { + if ( + !this.hass || + !this.hass.areas || + !this.hass.states || + !this.hass.entities + ) + return; + + const excludedDomains = [ + "person", + "backup", + "automation", + "script", + "device_tracker", + "calendar", + "area", + "zone", + "label", + "sun", + "tts", + "text", + "ai_task", + "group", + "conversation", + "event", + "weather", + "effortlesshome", + ]; + + const rawAreas = Object.values(this.hass.areas); + const areas = [...rawAreas, { name: "Unknown", area_id: "unknown" }]; + const entities = Object.values(this.hass.states); + const areaEntityMap = {}; + const domainsSet = new Set(); + + for (const area of areas) { + areaEntityMap[area.area_id] = []; + } + + for (const ent of entities) { + const domain = ent.entity_id.split(".")[0]; + if (excludedDomains.includes(domain)) continue; + + domainsSet.add(domain); + + const areaId = this.hass.entities[ent.entity_id]?.area_id; + const assignedArea = areaId || "unknown"; + if (!areaEntityMap[assignedArea]) { + areaEntityMap[assignedArea] = []; + } + areaEntityMap[assignedArea].push(ent.entity_id); + } + + this.areas = areas; + this.areaEntityMap = areaEntityMap; + this.domains = Array.from(domainsSet).sort(); + + if (!this.selectedDomain && this.domains.length > 0) { + this.selectedDomain = this.domains[0]; + } + + await this.updateComplete; + this._initSortable(); + } + + _initSortable() { + this.areas.forEach((area) => { + const container = this.renderRoot.querySelector(`#grid-${area.area_id}`); + if (!container) return; + Sortable.create(container, { + group: "shared", + animation: 150, + onAdd: (evt) => { + const entityId = evt.item.dataset.entity; + const newAreaId = area.area_id === "unknown" ? null : area.area_id; + + this.hass.callService("effortlesshome", "update_entity", { + entity_id: entityId, + area_id: newAreaId, + }); + }, + }); + }); + } + + _getFriendlyName(entityId) { + return this.hass.states[entityId]?.attributes.friendly_name || entityId; + } + + _handleDomainChange(e) { + this.selectedDomain = e.target.value; + } + + render() { + return html` +
+ +
+ Connecting...
+
+
+ Loading Matter bridges...
+No Matter bridges found.
"; + } + } catch (err) { + console.error("Failed to fetch Matter bridges:", err); + if (list) + list.innerHTML = + "Matter Hub unreachable or API failed.
"; + } + } + + _renderBridgeCard(bridge) { + const comm = bridge.commissioning || {}; + const info = bridge.basicInformation || {}; + + // Generate a unique id for the factory reset button + const resetBtnId = `factory-reset-btn-${bridge.id}`; + + // Attach the event listener after rendering + setTimeout(() => { + const btn = this.querySelector(`#${resetBtnId}`); + if (btn) { + btn.onclick = async () => { + if (!confirm("Factory reset this bridge? This cannot be undone.")) + return; + btn.disabled = true; + btn.textContent = "Resetting..."; + try { + const hostname = window.location.hostname; + const url = `http://${hostname}:8482/api/matter/bridges/${bridge.id}/actions/factory-reset`; + const resp = await fetch(url, { method: "GET" }); + if (!resp.ok) throw new Error("Factory reset failed"); + alert("Bridge factory reset successfully."); + } catch (err) { + alert("Factory reset failed: " + (err.message || err)); + } finally { + btn.disabled = false; + btn.textContent = "Factory Reset"; + } + }; + } + }, 0); + + return ` +