diff --git a/CODEOWNERS b/CODEOWNERS index bc489bbe4f9bc5..bf877ec95f1b06 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1055,6 +1055,7 @@ CLAUDE.md @home-assistant/core /homeassistant/components/lovelace/ @home-assistant/frontend /tests/components/lovelace/ @home-assistant/frontend /homeassistant/components/luci/ @mzdrale +/tests/components/luci/ @mzdrale /homeassistant/components/luftdaten/ @fabaff @frenck /tests/components/luftdaten/ @fabaff @frenck /homeassistant/components/lunatone/ @MoonDevLT @@ -1145,8 +1146,8 @@ CLAUDE.md @home-assistant/core /tests/components/moehlenhoff_alpha2/ @j-a-n /homeassistant/components/moisture/ @home-assistant/core /tests/components/moisture/ @home-assistant/core -/homeassistant/components/monarch_money/ @jeeftor -/tests/components/monarch_money/ @jeeftor +/homeassistant/components/monarch_money/ @jeeftor @bradleyseanf +/tests/components/monarch_money/ @jeeftor @bradleyseanf /homeassistant/components/monoprice/ @etsinko @OnFreund /tests/components/monoprice/ @etsinko @OnFreund /homeassistant/components/monzo/ @jakemartin-icl diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index 0a39f0ab6f4731..d905b3214bde81 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -204,7 +204,7 @@ def state(self) -> MediaPlayerState | None: return MediaPlayerState.PLAYING if state in (DeviceState.Paused, DeviceState.Seeking, DeviceState.Stopped): return MediaPlayerState.PAUSED - return MediaPlayerState.IDLE # Bad or unknown state? + return MediaPlayerState.IDLE # type: ignore[unreachable] # Bad or unknown state? return None @callback diff --git a/homeassistant/components/assist_satellite/llm.py b/homeassistant/components/assist_satellite/llm.py new file mode 100644 index 00000000000000..6590875fe4bc01 --- /dev/null +++ b/homeassistant/components/assist_satellite/llm.py @@ -0,0 +1,24 @@ +"""LLM tools for the assist_satellite integration.""" + +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return the broadcast LLM tool.""" + if api_id != LLM_API_ASSIST: + return None + + # assist_satellite registers the broadcast intent when it is set up, and + # this platform is only queried once that has happened. + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type == intent.INTENT_BROADCAST + ] + return LLMTools(tools=tools) diff --git a/homeassistant/components/bsblan/services.py b/homeassistant/components/bsblan/services.py index 315320fd5b8395..f48ea2552213bc 100644 --- a/homeassistant/components/bsblan/services.py +++ b/homeassistant/components/bsblan/services.py @@ -2,7 +2,7 @@ from datetime import time import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Final from bsblan import BSBLANError, DaySchedule, DHWSchedule, TimeSlot import voluptuous as vol @@ -29,6 +29,16 @@ ATTR_SATURDAY_SLOTS = "saturday_slots" ATTR_SUNDAY_SLOTS = "sunday_slots" +_DAY_NAME_SLOT_ATTR_PAIRS: tuple[tuple[str, str], ...] = ( + ("monday", ATTR_MONDAY_SLOTS), + ("tuesday", ATTR_TUESDAY_SLOTS), + ("wednesday", ATTR_WEDNESDAY_SLOTS), + ("thursday", ATTR_THURSDAY_SLOTS), + ("friday", ATTR_FRIDAY_SLOTS), + ("saturday", ATTR_SATURDAY_SLOTS), + ("sunday", ATTR_SUNDAY_SLOTS), +) + # Schema for a single time slot _SLOT_SCHEMA = vol.Schema( @@ -39,16 +49,16 @@ ) +_WEEKLY_SCHEDULE_FIELDS: Final[dict[vol.Marker, Any]] = { + vol.Optional(slot_attr): vol.All(cv.ensure_list, [_SLOT_SCHEMA]) + for _, slot_attr in _DAY_NAME_SLOT_ATTR_PAIRS +} + + SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA = vol.Schema( { vol.Required(ATTR_DEVICE_ID): cv.string, - vol.Optional(ATTR_MONDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), - vol.Optional(ATTR_TUESDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), - vol.Optional(ATTR_WEDNESDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), - vol.Optional(ATTR_THURSDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), - vol.Optional(ATTR_FRIDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), - vol.Optional(ATTR_SATURDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), - vol.Optional(ATTR_SUNDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), + **_WEEKLY_SCHEDULE_FIELDS, } ) @@ -98,11 +108,26 @@ def _convert_time_slots_to_day_schedule( return DaySchedule(slots=time_slots) -async def set_hot_water_schedule(service_call: ServiceCall) -> None: - """Set hot water heating schedule.""" - device_id = service_call.data[ATTR_DEVICE_ID] +def _build_weekly_schedule_days( + service_call: ServiceCall, +) -> dict[str, DaySchedule | None]: + """Build day-name -> schedule values from the service call data. + + Days omitted from the service call map to None, which tells python-bsblan not to + modify that day. + """ + return { + day_name: _convert_time_slots_to_day_schedule(service_call.data.get(attr_name)) + for day_name, attr_name in _DAY_NAME_SLOT_ATTR_PAIRS + } + + +def _resolve_config_entry( + service_call: ServiceCall, +) -> tuple[BSBLanConfigEntry, dr.DeviceEntry]: + """Resolve device_id from a service call into a loaded BSBLAN config entry.""" + device_id: str = service_call.data[ATTR_DEVICE_ID] - # Get the device and config entry device_registry = dr.async_get(service_call.hass) device_entry = device_registry.async_get(device_id) @@ -137,56 +162,20 @@ async def set_hot_water_schedule(service_call: ServiceCall) -> None: translation_placeholders={"device_name": device_entry.name or device_id}, ) - client = entry.runtime_data.client + return entry, device_entry - # Convert time slots to DaySchedule objects - monday = _convert_time_slots_to_day_schedule( - service_call.data.get(ATTR_MONDAY_SLOTS) - ) - tuesday = _convert_time_slots_to_day_schedule( - service_call.data.get(ATTR_TUESDAY_SLOTS) - ) - wednesday = _convert_time_slots_to_day_schedule( - service_call.data.get(ATTR_WEDNESDAY_SLOTS) - ) - thursday = _convert_time_slots_to_day_schedule( - service_call.data.get(ATTR_THURSDAY_SLOTS) - ) - friday = _convert_time_slots_to_day_schedule( - service_call.data.get(ATTR_FRIDAY_SLOTS) - ) - saturday = _convert_time_slots_to_day_schedule( - service_call.data.get(ATTR_SATURDAY_SLOTS) - ) - sunday = _convert_time_slots_to_day_schedule( - service_call.data.get(ATTR_SUNDAY_SLOTS) - ) - # Create the DHWSchedule object - dhw_schedule = DHWSchedule( - monday=monday, - tuesday=tuesday, - wednesday=wednesday, - thursday=thursday, - friday=friday, - saturday=saturday, - sunday=sunday, - ) +async def set_hot_water_schedule(service_call: ServiceCall) -> None: + """Set hot water heating schedule.""" + entry, _ = _resolve_config_entry(service_call) + client = entry.runtime_data.client - LOGGER.debug( - "Setting hot water schedule - Monday: %s, Tuesday: %s, Wednesday: %s, " - "Thursday: %s, Friday: %s, Saturday: %s, Sunday: %s", - monday, - tuesday, - wednesday, - thursday, - friday, - saturday, - sunday, - ) + days = _build_weekly_schedule_days(service_call) + dhw_schedule = DHWSchedule(**days) + + LOGGER.debug("Setting hot water schedule: %s", dhw_schedule) try: - # Call the BSB-LAN API to set the schedule await client.set_hot_water_schedule(dhw_schedule) except BSBLANError as err: raise HomeAssistantError( @@ -201,45 +190,11 @@ async def set_hot_water_schedule(service_call: ServiceCall) -> None: async def async_sync_time(service_call: ServiceCall) -> None: """Synchronize BSB-LAN device time with Home Assistant.""" - device_id: str = service_call.data[ATTR_DEVICE_ID] - - # Get the device and config entry - device_registry = dr.async_get(service_call.hass) - device_entry = device_registry.async_get(device_id) - - if device_entry is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_device_id", - translation_placeholders={"device_id": device_id}, - ) - - # Find the config entry for this device - matching_entries: list[BSBLanConfigEntry] = [ - entry - for entry in service_call.hass.config_entries.async_entries(DOMAIN) - if entry.entry_id in device_entry.config_entries - ] - - if not matching_entries: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_config_entry_for_device", - translation_placeholders={"device_id": device_entry.name or device_id}, - ) - - entry = matching_entries[0] - - # Verify the config entry is loaded - if entry.state is not ConfigEntryState.LOADED: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="config_entry_not_loaded", - translation_placeholders={"device_name": device_entry.name or device_id}, - ) - + entry, device_entry = _resolve_config_entry(service_call) client = entry.runtime_data.client - await async_sync_device_time(client, device_entry.name or device_id) + await async_sync_device_time( + client, device_entry.name or service_call.data[ATTR_DEVICE_ID] + ) SYNC_TIME_SCHEMA = vol.Schema( diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index 2f73f7c33d8f24..31ac35cb756c80 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -151,8 +151,7 @@ async def call_action( if isinstance( automation_result, ScriptRunResult ) and automation_result.conversation_response not in (None, UNDEFINED): - # mypy does not understand the type narrowing, unclear why - return automation_result.conversation_response # type: ignore[return-value] + return automation_result.conversation_response # It's important to return None here instead of a string. # diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 8f1489256a6a2a..6e50faa06eb204 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -36,7 +36,11 @@ LOGGER, PLATFORM_TYPE_LEGACY, SCAN_INTERVAL, + DeviceTrackerEntityCapabilityAttribute, + DeviceTrackerEntityStateAttribute, + ScannerEntityStateAttribute, SourceType, + TrackerEntityStateAttribute, TrackingType, ) from .entity import ( # noqa: F401 diff --git a/homeassistant/components/etherscan/__init__.py b/homeassistant/components/etherscan/__init__.py index 0e983bd6bead85..b7665204e229fa 100644 --- a/homeassistant/components/etherscan/__init__.py +++ b/homeassistant/components/etherscan/__init__.py @@ -1 +1 @@ -"""The etherscan component.""" +"""The Etherscan integration.""" diff --git a/homeassistant/components/google_health/config_flow.py b/homeassistant/components/google_health/config_flow.py index 25d21a36ac20ba..4450e44e6fe7c0 100644 --- a/homeassistant/components/google_health/config_flow.py +++ b/homeassistant/components/google_health/config_flow.py @@ -6,14 +6,17 @@ from google_health_api import GoogleHealthApi from google_health_api.const import HealthApiScope -from google_health_api.exceptions import GoogleHealthApiError +from google_health_api.exceptions import ( + GoogleHealthApiError, + HealthApiForbiddenException, +) from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from .api import SimpleAuth -from .const import DEFAULT_TITLE, DOMAIN, OAUTH_SCOPES +from .const import API_CONSOLE_URL, DEFAULT_TITLE, DOMAIN, OAUTH_SCOPES _LOGGER = logging.getLogger(__name__) @@ -68,6 +71,12 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResu try: identity = await api.get_identity() + except HealthApiForbiddenException as err: + _LOGGER.error("Error getting Google Health identity: %s", err) + return self.async_abort( + reason="api_not_enabled", + description_placeholders={"url": API_CONSOLE_URL}, + ) except GoogleHealthApiError as err: _LOGGER.error("Error getting Google Health identity: %s", err) return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/google_health/const.py b/homeassistant/components/google_health/const.py index 03243729ae2695..fc4660cc069573 100644 --- a/homeassistant/components/google_health/const.py +++ b/homeassistant/components/google_health/const.py @@ -7,6 +7,10 @@ OAUTH2_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth" OAUTH2_TOKEN = "https://oauth2.googleapis.com/token" +API_CONSOLE_URL = ( + "https://console.developers.google.com/apis/api/health.googleapis.com/overview" +) + DEFAULT_TITLE = "Google Health" OAUTH_SCOPES = [ diff --git a/homeassistant/components/google_health/strings.json b/homeassistant/components/google_health/strings.json index 3921b4deaa8453..8159cafe0444f6 100644 --- a/homeassistant/components/google_health/strings.json +++ b/homeassistant/components/google_health/strings.json @@ -6,6 +6,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "api_not_enabled": "The Google Health API is not enabled for your Google Cloud project. Enable it in the [Google Cloud Console]({url}), wait a few minutes for the change to propagate, then try again.", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "cannot_connect": "Failed to connect.", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index b38caf53c33618..3dc73beb3e83e8 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -60,6 +60,7 @@ DATA_HASSIO_SUPERVISOR_USER, DATA_KEY_SUPERVISOR_ISSUES, DOMAIN, + JOBS_COORDINATOR, MAIN_COORDINATOR, STATS_COORDINATOR, ) @@ -67,6 +68,7 @@ HassioAddOnDataUpdateCoordinator, HassioMainDataUpdateCoordinator, HassioStatsDataUpdateCoordinator, + SupervisorJobsCoordinator, get_addons_info, get_addons_list, get_addons_stats, @@ -326,9 +328,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() hass.data[MAIN_COORDINATOR] = coordinator - addon_coordinator = HassioAddOnDataUpdateCoordinator( - hass, entry, dev_reg, coordinator.jobs - ) + jobs_coordinator = SupervisorJobsCoordinator(hass, entry) + await jobs_coordinator.async_config_entry_first_refresh() + hass.data[JOBS_COORDINATOR] = jobs_coordinator + + addon_coordinator = HassioAddOnDataUpdateCoordinator(hass, entry, dev_reg) await addon_coordinator.async_config_entry_first_refresh() hass.data[ADDONS_COORDINATOR] = addon_coordinator @@ -437,5 +441,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.pop(MAIN_COORDINATOR, None) hass.data.pop(ADDONS_COORDINATOR, None) hass.data.pop(STATS_COORDINATOR, None) + hass.data.pop(JOBS_COORDINATOR, None) return unload_ok diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 418f956c46c268..8f68e090323879 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -27,6 +27,7 @@ HassioAddOnDataUpdateCoordinator, HassioMainDataUpdateCoordinator, HassioStatsDataUpdateCoordinator, + SupervisorJobsCoordinator, ) from .handler import HassIO from .issues import SupervisorIssues @@ -103,6 +104,9 @@ STATS_COORDINATOR: HassKey[HassioStatsDataUpdateCoordinator] = HassKey( "hassio_stats_coordinator" ) +JOBS_COORDINATOR: HassKey[SupervisorJobsCoordinator] = HassKey( + "hassio_jobs_coordinator" +) DATA_COMPONENT: HassKey[HassIO] = HassKey(DOMAIN) @@ -126,6 +130,7 @@ HASSIO_MAIN_UPDATE_INTERVAL = timedelta(minutes=5) HASSIO_ADDON_UPDATE_INTERVAL = timedelta(minutes=15) HASSIO_STATS_UPDATE_INTERVAL = timedelta(seconds=60) +SUPERVISOR_JOBS_UPDATE_INTERVAL = timedelta(minutes=15) ATTR_AUTO_UPDATE = "auto_update" ATTR_VERSION = "version" diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index b08cd67939eb59..aeadbb419fe25f 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -2,10 +2,11 @@ import asyncio from collections import defaultdict -from collections.abc import Awaitable -from dataclasses import dataclass +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, replace import logging from typing import TYPE_CHECKING, Any, cast, override +from uuid import UUID from aiohasupervisor import SupervisorError, SupervisorNotFoundError from aiohasupervisor.models import ( @@ -17,6 +18,7 @@ HostInfo, InstalledAddon, InstalledAddonComplete, + Job, NetworkInfo, NFSMountResponse, OSInfo, @@ -29,7 +31,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MANUFACTURER -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import ( + CALLBACK_TYPE, + HomeAssistant, + callback, + is_callback_check_partial, +) from homeassistant.helpers import device_registry as dr from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo @@ -59,6 +66,7 @@ DATA_SUPERVISOR_INFO, DATA_SUPERVISOR_STATS, DOMAIN, + EVENT_JOB, EVENT_SUPERVISOR_EVENT, EVENT_SUPERVISOR_UPDATE, HASSIO_ADDON_UPDATE_INTERVAL, @@ -67,12 +75,12 @@ REQUEST_REFRESH_DELAY, STARTUP_COMPLETE, SUPERVISOR_CONTAINER, + SUPERVISOR_JOBS_UPDATE_INTERVAL, UPDATE_KEY_SUPERVISOR, SupervisorEntityModel, ) from .exceptions import HassioNotReadyError from .handler import get_supervisor_client -from .jobs import SupervisorJobs if TYPE_CHECKING: from .issues import SupervisorIssues @@ -80,6 +88,200 @@ _LOGGER = logging.getLogger(__name__) +@dataclass(slots=True, frozen=True) +class JobSubscription: + """Subscribe for updates on jobs which match filters. + + UUID is preferred match but only available in cases of a background API that + returns the UUID before taking the action. Others are used to match jobs only + if UUID is omitted. Either name or UUID is required to be able to match. + + event_callback must be safe annotated as a homeassistant.core.callback + and safe to call in the event loop. + """ + + event_callback: Callable[[Job], None] + uuid: str | None = None + name: str | None = None + reference: str | None = None + + def __post_init__(self) -> None: + """Validate at least one filter option is present.""" + if not self.name and not self.uuid: + raise ValueError("Either name or uuid must be provided!") + if not is_callback_check_partial(self.event_callback): + raise ValueError("event_callback must be a homeassistant.core.callback!") + + def matches(self, job: Job) -> bool: + """Return true if job matches subscription filters.""" + if self.uuid: + return job.uuid == self.uuid + return job.name == self.name and self.reference in (None, job.reference) + + +class SupervisorJobsCoordinator(DataUpdateCoordinator[dict[UUID, Job]]): + """Manage access to Supervisor jobs.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize object.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="SupervisorJobsCoordinator", + update_interval=SUPERVISOR_JOBS_UPDATE_INTERVAL, + # We don't want an immediate refresh since we want to avoid + # hammering the Supervisor API on startup + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), + ) + self._supervisor_client = get_supervisor_client(hass) + self._subscriptions: set[JobSubscription] = set() + self._dispatcher_disconnect: Callable[[], None] | None = None + self._noop_listener_disconnect: Callable[[], None] | None = None + + @property + def current_jobs(self) -> list[Job]: + """Return current jobs.""" + return list(self.data.values()) if self.data is not None else [] + + @staticmethod + def _build_jobs(jobs: list[Job]) -> dict[UUID, Job]: + """Flatten jobs and child jobs into a UUID keyed cache.""" + job_queue: list[Job] = jobs.copy() + cached_jobs: dict[UUID, Job] = {} + + while job_queue: + job = job_queue.pop(0) + job_queue.extend(job.child_jobs) + cached_jobs[job.uuid] = replace(job, child_jobs=[]) + + return cached_jobs + + @override + async def _async_update_data(self) -> dict[UUID, Job]: + """Fetch data from Supervisor.""" + job_data = await self._supervisor_client.jobs.info() + return self._build_jobs(job_data.jobs) + + def _process_job_change(self, job: Job) -> None: + """Process a job change by triggering callbacks on subscribers.""" + for sub in self._subscriptions: + if sub.matches(job): + sub.event_callback(job) + + def _process_job_deltas( + self, + previous_jobs: dict[UUID, Job], + current_jobs: dict[UUID, Job], + ) -> None: + """Notify subscribers about changes between two job caches.""" + for job in current_jobs.values(): + if (previous_job := previous_jobs.get(job.uuid)) is not None and ( + previous_job == job + ): + continue + self._process_job_change(job) + + for uuid, job in previous_jobs.items(): + if uuid not in current_jobs and job.done is False: + self._process_job_change(replace(job, done=True)) + + def subscribe(self, subscription: JobSubscription) -> CALLBACK_TYPE: + """Subscribe to updates for job. Return callback is used to unsubscribe. + + If any jobs match the subscription at the time this is called, runs the + callback on them. + """ + self._subscriptions.add(subscription) + + # Connect a stub listener to start the update interval polling on first subscriber + if self._noop_listener_disconnect is None: + self._noop_listener_disconnect = self.async_add_listener(lambda: None) + + # Run the callback on each existing match + # We catch all errors to prevent an error in one from stopping the others + for match in [job for job in self.current_jobs if subscription.matches(job)]: + try: + subscription.event_callback(match) + except Exception as err: # noqa: BLE001 + _LOGGER.error( + "Error encountered processing Supervisor Job (%s %s %s) - %s", + match.name, + match.reference, + match.uuid, + err, + ) + + def _unsubscribe() -> None: + self._subscriptions.discard(subscription) + + # Stop polling if there are no more subscribers + if not self._subscriptions and self._noop_listener_disconnect is not None: + self._noop_listener_disconnect() + self._noop_listener_disconnect = None + + return _unsubscribe + + @callback + @override + def _async_refresh_finished(self) -> None: + """Register to receive Supervisor events after the first successful refresh.""" + if self.last_update_success and self._dispatcher_disconnect is None: + self._dispatcher_disconnect = async_dispatcher_connect( + self.hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_jobs + ) + + @override + async def _async_refresh( + self, + log_failures: bool = True, + raise_on_auth_failed: bool = False, + scheduled: bool = False, + raise_on_entry_error: bool = False, + ) -> None: + """Refresh data and notify subscribers about cache changes.""" + previous_jobs = self.data or {} + await super()._async_refresh( + log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error + ) + if self.last_update_success and self.data is not None: + self._process_job_deltas(previous_jobs, self.data) + + @override + async def async_shutdown(self) -> None: + """Shut down the coordinator.""" + await super().async_shutdown() + if self._dispatcher_disconnect: + self._dispatcher_disconnect() + self._dispatcher_disconnect = None + + @callback + def _supervisor_events_to_jobs(self, event: dict[str, Any]) -> None: + """Update job data cache from supervisor events.""" + if ATTR_WS_EVENT not in event: + return + + if ( + event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE + and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR + and event.get(ATTR_DATA, {}).get(ATTR_STARTUP) == STARTUP_COMPLETE + ): + self.config_entry.async_create_task(self.hass, self.async_request_refresh()) + + elif event[ATTR_WS_EVENT] == EVENT_JOB: + job = Job.from_dict(event[ATTR_DATA] | {"child_jobs": []}) + previous_jobs = self.data or {} + updated_jobs = {**previous_jobs, job.uuid: job} + if job.done: + updated_jobs.pop(job.uuid, None) + self.async_set_updated_data(updated_jobs) + self._process_job_change(job) + + @dataclass class HassioMainData: """Data class for HassioMainDataUpdateCoordinator.""" @@ -591,7 +793,6 @@ def __init__( hass: HomeAssistant, config_entry: ConfigEntry, dev_reg: dr.DeviceRegistry, - jobs: SupervisorJobs, ) -> None: """Initialize coordinator.""" super().__init__( @@ -610,7 +811,6 @@ def __init__( self.dev_reg = dev_reg self._addon_info_subscriptions: defaultdict[str, set[str]] = defaultdict(set) self.supervisor_client = get_supervisor_client(hass) - self.jobs = jobs @override async def _async_update_data(self) -> HassioAddonData: @@ -800,7 +1000,6 @@ def __init__( self.dev_reg = dev_reg self.is_hass_os = False self.supervisor_client = get_supervisor_client(hass) - self.jobs = SupervisorJobs(hass) self._dispatcher_disconnect = async_dispatcher_connect( hass, EVENT_SUPERVISOR_EVENT, self._supervisor_event ) @@ -854,7 +1053,6 @@ async def _async_update_data(self) -> HassioMainData: ), ) mounts_info = await client.mounts.info() - await self.jobs.refresh_data(is_first_update) except SupervisorError as err: raise UpdateFailed(f"Error on Supervisor API: {err}") from err @@ -951,4 +1149,3 @@ async def async_shutdown(self) -> None: """Shut down and clean up when config entry unloaded.""" await super().async_shutdown() self._dispatcher_disconnect() - self.jobs.unload() diff --git a/homeassistant/components/hassio/jobs.py b/homeassistant/components/hassio/jobs.py deleted file mode 100644 index a7445b33b5e99e..00000000000000 --- a/homeassistant/components/hassio/jobs.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Track Supervisor job data and allow subscription to updates.""" - -from collections.abc import Callable -from dataclasses import dataclass, replace -from functools import partial -import logging -from typing import Any -from uuid import UUID - -from aiohasupervisor.models import Job - -from homeassistant.core import ( - CALLBACK_TYPE, - HomeAssistant, - callback, - is_callback_check_partial, -) -from homeassistant.helpers.dispatcher import async_dispatcher_connect - -from .const import ( - ATTR_DATA, - ATTR_STARTUP, - ATTR_UPDATE_KEY, - ATTR_WS_EVENT, - EVENT_JOB, - EVENT_SUPERVISOR_EVENT, - EVENT_SUPERVISOR_UPDATE, - STARTUP_COMPLETE, - UPDATE_KEY_SUPERVISOR, -) -from .handler import get_supervisor_client - -_LOGGER = logging.getLogger(__name__) - - -@dataclass(slots=True, frozen=True) -class JobSubscription: - """Subscribe for updates on jobs which match filters. - - UUID is preferred match but only available in cases of a background API that - returns the UUID before taking the action. Others are used to match jobs only - if UUID is omitted. Either name or UUID is required to be able to match. - - event_callback must be safe annotated as a homeassistant.core.callback - and safe to call in the event loop. - """ - - event_callback: Callable[[Job], Any] - uuid: str | None = None - name: str | None = None - reference: str | None = None - - def __post_init__(self) -> None: - """Validate at least one filter option is present.""" - if not self.name and not self.uuid: - raise ValueError("Either name or uuid must be provided!") - if not is_callback_check_partial(self.event_callback): - raise ValueError("event_callback must be a homeassistant.core.callback!") - - def matches(self, job: Job) -> bool: - """Return true if job matches subscription filters.""" - if self.uuid: - return job.uuid == self.uuid - return job.name == self.name and self.reference in (None, job.reference) - - -class SupervisorJobs: - """Manage access to Supervisor jobs.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize object.""" - self._hass = hass - self._supervisor_client = get_supervisor_client(hass) - self._jobs: dict[UUID, Job] = {} - self._subscriptions: set[JobSubscription] = set() - self._dispatcher_disconnect: Callable[[], None] | None = None - - @property - def current_jobs(self) -> list[Job]: - """Return current jobs.""" - return list(self._jobs.values()) - - def subscribe(self, subscription: JobSubscription) -> CALLBACK_TYPE: - """Subscribe to updates for job. Return callback is used to unsubscribe. - - If any jobs match the subscription at the time this is called, runs the - callback on them. - """ - self._subscriptions.add(subscription) - - # Run the callback on each existing match - # We catch all errors to prevent an error in one from stopping the others - for match in [job for job in self._jobs.values() if subscription.matches(job)]: - try: - subscription.event_callback(match) - except Exception as err: # noqa: BLE001 - _LOGGER.error( - "Error encountered processing Supervisor Job (%s %s %s) - %s", - match.name, - match.reference, - match.uuid, - err, - ) - - return partial(self._subscriptions.discard, subscription) - - async def refresh_data(self, first_update: bool = False) -> None: - """Refresh job data.""" - job_data = await self._supervisor_client.jobs.info() - job_queue: list[Job] = job_data.jobs.copy() - new_jobs: dict[UUID, Job] = {} - changed_jobs: list[Job] = [] - - # Rebuild our job cache from new info and compare to find changes - while job_queue: - job = job_queue.pop(0) - job_queue.extend(job.child_jobs) - job = replace(job, child_jobs=[]) - - if job.uuid not in self._jobs or job != self._jobs[job.uuid]: - changed_jobs.append(job) - new_jobs[job.uuid] = replace(job, child_jobs=[]) - - # For any jobs that disappeared which weren't done, tell subscribers they - # changed to done. We don't know what else happened to them so leave the - # rest of their state as is rather then guessing - changed_jobs.extend( - [ - replace(job, done=True) - for uuid, job in self._jobs.items() - if uuid not in new_jobs and job.done is False - ] - ) - - # Replace our cache and inform subscribers of all changes - self._jobs = new_jobs - for job in changed_jobs: - self._process_job_change(job) - - # If this is the first update register to receive Supervisor events - if first_update: - self._dispatcher_disconnect = async_dispatcher_connect( - self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_jobs - ) - - @callback - def _supervisor_events_to_jobs(self, event: dict[str, Any]) -> None: - """Update job data cache from supervisor events.""" - if ATTR_WS_EVENT not in event: - return - - if ( - event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE - and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR - and event.get(ATTR_DATA, {}).get(ATTR_STARTUP) == STARTUP_COMPLETE - ): - self._hass.async_create_task(self.refresh_data()) - - elif event[ATTR_WS_EVENT] == EVENT_JOB: - job = Job.from_dict(event[ATTR_DATA] | {"child_jobs": []}) - self._jobs[job.uuid] = job - self._process_job_change(job) - - def _process_job_change(self, job: Job) -> None: - """Process a job change by triggering callbacks on subscribers.""" - for sub in self._subscriptions: - if sub.matches(job): - sub.event_callback(job) - - # If the job is done, pop it from our cache if present after processing is done - if job.done and job.uuid in self._jobs: - del self._jobs[job.uuid] - - @callback - def unload(self) -> None: - """Unregister with dispatcher on config entry unload.""" - if self._dispatcher_disconnect: - self._dispatcher_disconnect() - self._dispatcher_disconnect = None diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index 10b0c10362fcde..ff5d25ba7df37d 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -17,15 +17,19 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ADDONS_COORDINATOR, ATTR_VERSION_LATEST, MAIN_COORDINATOR -from .coordinator import AddonData +from .const import ( + ADDONS_COORDINATOR, + ATTR_VERSION_LATEST, + JOBS_COORDINATOR, + MAIN_COORDINATOR, +) +from .coordinator import AddonData, JobSubscription from .entity import ( HassioAddonEntity, HassioCoreEntity, HassioOSEntity, HassioSupervisorEntity, ) -from .jobs import JobSubscription from .update_helper import update_addon, update_core, update_os ENTITY_DESCRIPTION = UpdateEntityDescription( @@ -220,7 +224,7 @@ async def async_added_to_hass(self) -> None: """Subscribe to progress updates.""" await super().async_added_to_hass() self.async_on_remove( - self.coordinator.jobs.subscribe( + self.hass.data[JOBS_COORDINATOR].subscribe( JobSubscription( self._update_job_changed, name="addon_manager_update", @@ -398,7 +402,7 @@ async def async_added_to_hass(self) -> None: """Subscribe to progress updates.""" await super().async_added_to_hass() self.async_on_remove( - self.coordinator.jobs.subscribe( + self.hass.data[JOBS_COORDINATOR].subscribe( JobSubscription(self._update_job_changed, name="supervisor_update") ) ) @@ -468,7 +472,7 @@ async def async_added_to_hass(self) -> None: """Subscribe to progress updates.""" await super().async_added_to_hass() self.async_on_remove( - self.coordinator.jobs.subscribe( + self.hass.data[JOBS_COORDINATOR].subscribe( JobSubscription( self._update_job_changed, name="home_assistant_core_update" ) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 1141b3a2973763..94c8c7216af661 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -104,7 +104,7 @@ if program not in (ProgramKey.UNKNOWN, *FAVORITE_PROGRAMS) } -PROGRAMS_TRANSLATION_KEYS_MAP = { +PROGRAMS_TRANSLATION_KEYS_MAP: dict[ProgramKey, str] = { value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items() } diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 85a4c3ead687c3..9051f423fddd3d 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -471,7 +471,7 @@ def register_redirect( async def redirect(request: web.Request) -> web.StreamResponse: """Redirect to location.""" # Should be instance of aiohttp.web_exceptions._HTTPMove. - raise redirect_exc(redirect_to) # type: ignore[arg-type,misc] + raise redirect_exc(redirect_to) # type: ignore[arg-type,call-arg] self.app[KEY_ALLOW_CONFIGURED_CORS]( self.app.router.add_route("GET", url, redirect) diff --git a/homeassistant/components/influxdb/__init__.py b/homeassistant/components/influxdb/__init__.py index 09f3b795cbd963..fb11066fbb097a 100644 --- a/homeassistant/components/influxdb/__init__.py +++ b/homeassistant/components/influxdb/__init__.py @@ -39,6 +39,7 @@ EVENT_STATE_CHANGED, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.data_entry_flow import FlowResultType @@ -252,7 +253,9 @@ def event_to_json(event: Event) -> dict[str, Any] | None: if measurement_attr == "entity_id": measurement = state.entity_id elif measurement_attr == "domain__device_class": - device_class = state.attributes.get("device_class") + device_class = state.attributes.get( + EntityStateAttribute.DEVICE_CLASS + ) if device_class is None: # This entity doesn't have a device_class set, use only domain measurement = state.domain diff --git a/homeassistant/components/integration/config_flow.py b/homeassistant/components/integration/config_flow.py index 7d00d2161cc0e4..718fee7893c5b9 100644 --- a/homeassistant/components/integration/config_flow.py +++ b/homeassistant/components/integration/config_flow.py @@ -8,12 +8,7 @@ from homeassistant.components.counter import DOMAIN as COUNTER_DOMAIN from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, - CONF_METHOD, - CONF_NAME, - UnitOfTime, -) +from homeassistant.const import CONF_METHOD, CONF_NAME, EntityStateAttribute, UnitOfTime from homeassistant.core import callback from homeassistant.helpers import selector from homeassistant.helpers.schema_config_entry_flow import ( @@ -62,13 +57,16 @@ def entity_selector_compatible( """Return an entity selector which compatible entities.""" current = handler.hass.states.get(handler.options[CONF_SOURCE_SENSOR]) unit_of_measurement = ( - current.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if current else None + current.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + if current + else None ) entities = [ ent.entity_id for ent in handler.hass.states.async_all(ALLOWED_DOMAINS) - if ent.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == unit_of_measurement + if ent.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + == unit_of_measurement and ent.domain in ALLOWED_DOMAINS ] diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 83b93c4191d034..edd7c5d0c1d0fc 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -20,12 +20,11 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, CONF_METHOD, CONF_NAME, CONF_UNIQUE_ID, STATE_UNAVAILABLE, + EntityStateAttribute, UnitOfTime, ) from homeassistant.core import ( @@ -389,7 +388,9 @@ def _calculate_device_class( return device_class def _derive_and_set_attributes_from_state(self, source_state: State) -> None: - source_unit = source_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + source_unit = source_state.attributes.get( + EntityStateAttribute.UNIT_OF_MEASUREMENT + ) if source_unit is not None: self._unit_of_measurement = self._calculate_unit(source_unit) else: @@ -397,7 +398,8 @@ def _derive_and_set_attributes_from_state(self, source_state: State) -> None: self._unit_of_measurement = None self._attr_device_class = self._calculate_device_class( - source_state.attributes.get(ATTR_DEVICE_CLASS), self.unit_of_measurement + source_state.attributes.get(EntityStateAttribute.DEVICE_CLASS), + self.unit_of_measurement, ) if self._attr_device_class: # Remove this sensors icon default and allow diff --git a/homeassistant/components/lcn/device_trigger.py b/homeassistant/components/lcn/device_trigger.py index 2421fa0b9f5ecb..799103e71c0605 100644 --- a/homeassistant/components/lcn/device_trigger.py +++ b/homeassistant/components/lcn/device_trigger.py @@ -1,5 +1,7 @@ """Provides device triggers for LCN.""" +from typing import Any + import voluptuous as vol from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA @@ -75,7 +77,7 @@ async def async_attach_trigger( trigger_info: TriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" - event_data = { + event_data: dict[str, Any] = { CONF_DEVICE_ID: config[CONF_DEVICE_ID], **{ key: config[key] diff --git a/homeassistant/components/luci/__init__.py b/homeassistant/components/luci/__init__.py index b0efa61ae77832..f39ef47578c604 100644 --- a/homeassistant/components/luci/__init__.py +++ b/homeassistant/components/luci/__init__.py @@ -1 +1,59 @@ """The luci component.""" + +from openwrt_luci_rpc import OpenWrtRpc +from requests.exceptions import ConnectionError as RequestsConnectionError + +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady + +from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, PLATFORMS +from .coordinator import LuciConfigEntry, LuciCoordinator + + +def _connect( + host: str, username: str, password: str, ssl: bool, verify_ssl: bool +) -> OpenWrtRpc: + """Connect to the router and verify login.""" + router = OpenWrtRpc(host, username, password, ssl, verify_ssl) + if not router.is_logged_in(): + raise ConfigEntryAuthFailed("Invalid credentials for router") + return router + + +async def async_setup_entry(hass: HomeAssistant, entry: LuciConfigEntry) -> bool: + """Set up OpenWrt (luci) from a config entry.""" + try: + router = await hass.async_add_executor_job( + _connect, + entry.data[CONF_HOST], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data.get(CONF_SSL, DEFAULT_SSL), + entry.data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), + ) + except (ConnectionError, RequestsConnectionError) as err: + raise ConfigEntryNotReady( + f"Cannot connect to router at {entry.data[CONF_HOST]}" + ) from err + + coordinator = LuciCoordinator(hass, entry, router) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: LuciConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/luci/config_flow.py b/homeassistant/components/luci/config_flow.py new file mode 100644 index 00000000000000..c5c8a191ab1f98 --- /dev/null +++ b/homeassistant/components/luci/config_flow.py @@ -0,0 +1,139 @@ +"""Config flow for the OpenWrt (luci) integration.""" + +from collections.abc import Mapping +from typing import Any, override + +from openwrt_luci_rpc import OpenWrtRpc +from requests.exceptions import ConnectionError as RequestsConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) + +from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, + } +) + + +class InvalidAuth(Exception): + """Raised when authentication fails.""" + + +class LuciConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for OpenWrt (luci).""" + + VERSION = 1 + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + + try: + await self.hass.async_add_executor_job(_try_connect, user_input) + except ConnectionError, RequestsConnectionError: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + else: + return self.async_create_entry( + title=user_input[CONF_HOST], + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauthentication.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauthentication confirmation.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + try: + await self.hass.async_add_executor_job( + _try_connect, reauth_entry.data | user_input + ) + except ConnectionError, RequestsConnectionError: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + else: + return self.async_update_reload_and_abort( + reauth_entry, data_updates=user_input + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: + """Handle import from legacy YAML configuration.""" + self._async_abort_entries_match({CONF_HOST: import_data[CONF_HOST]}) + + try: + await self.hass.async_add_executor_job(_try_connect, import_data) + except ConnectionError, RequestsConnectionError: + return self.async_abort(reason="cannot_connect") + except InvalidAuth: + return self.async_abort(reason="invalid_auth") + + return self.async_create_entry( + title=import_data[CONF_HOST], + data=import_data, + ) + + +def _try_connect(user_input: dict[str, Any]) -> None: + """Try to connect and authenticate with the router.""" + router = OpenWrtRpc( + user_input[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + user_input.get(CONF_SSL, DEFAULT_SSL), + user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), + ) + if not router.is_logged_in(): + raise InvalidAuth diff --git a/homeassistant/components/luci/const.py b/homeassistant/components/luci/const.py new file mode 100644 index 00000000000000..e8d34b6b3354ef --- /dev/null +++ b/homeassistant/components/luci/const.py @@ -0,0 +1,10 @@ +"""Constants for the OpenWrt (luci) integration.""" + +from homeassistant.const import Platform + +DOMAIN = "luci" + +PLATFORMS = [Platform.DEVICE_TRACKER] + +DEFAULT_SSL = True +DEFAULT_VERIFY_SSL = False diff --git a/homeassistant/components/luci/coordinator.py b/homeassistant/components/luci/coordinator.py new file mode 100644 index 00000000000000..c57f7d0f3b597b --- /dev/null +++ b/homeassistant/components/luci/coordinator.py @@ -0,0 +1,55 @@ +"""DataUpdateCoordinator for the OpenWrt (luci) integration.""" + +from datetime import timedelta +import logging +from typing import Any, override + +from openwrt_luci_rpc import OpenWrtRpc +from openwrt_luci_rpc.exceptions import LuciRpcUnknownError +from requests.exceptions import ConnectionError as RequestsConnectionError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=30) + +type LuciConfigEntry = ConfigEntry[LuciCoordinator] + + +class LuciCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinator for fetching connected devices from an OpenWrt router.""" + + config_entry: LuciConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: LuciConfigEntry, + router: OpenWrtRpc, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="luci", + update_interval=SCAN_INTERVAL, + ) + self.router = router + + @override + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from the router.""" + try: + result = await self.hass.async_add_executor_job( + lambda: self.router.get_all_connected_devices(only_reachable=True) + ) + except (ConnectionError, RequestsConnectionError, LuciRpcUnknownError) as err: + raise UpdateFailed(f"Error communicating with router: {err}") from err + + _LOGGER.debug("Luci get_all_connected_devices returned: %s", result) + + return {device.mac: device for device in result} diff --git a/homeassistant/components/luci/device_tracker.py b/homeassistant/components/luci/device_tracker.py index 172e6cd5422d38..e4f87070c787f3 100644 --- a/homeassistant/components/luci/device_tracker.py +++ b/homeassistant/components/luci/device_tracker.py @@ -1,16 +1,16 @@ """Support for OpenWRT (luci) routers.""" import logging -from typing import override +from typing import Any, override -from openwrt_luci_rpc import OpenWrtRpc import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, - DeviceScanner, + AsyncSeeCallback, + ScannerEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -18,14 +18,20 @@ CONF_USERNAME, CONF_VERIFY_SSL, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.typing import ConfigType +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN +from .coordinator import LuciConfigEntry, LuciCoordinator _LOGGER = logging.getLogger(__name__) -DEFAULT_SSL = False -DEFAULT_VERIFY_SSL = True +PARALLEL_UPDATES = 0 PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( { @@ -38,73 +44,111 @@ ) -def get_scanner(hass: HomeAssistant, config: ConfigType) -> LuciDeviceScanner | None: - """Validate the configuration and return a Luci scanner.""" - scanner = LuciDeviceScanner(config[DEVICE_TRACKER_DOMAIN]) - - return scanner if scanner.success_init else None - - -class LuciDeviceScanner(DeviceScanner): - """Scanner for devices connected to an OpenWrt router.""" - - def __init__(self, config): - """Initialize the scanner.""" - - self.router = OpenWrtRpc( - config[CONF_HOST], - config[CONF_USERNAME], - config[CONF_PASSWORD], - config[CONF_SSL], - config[CONF_VERIFY_SSL], - ) - - self.last_results = {} - self.success_init = self.router.is_logged_in() - +async def async_setup_scanner( + hass: HomeAssistant, + config: ConfigType, + async_see: AsyncSeeCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> bool: + """Import legacy YAML configuration.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: config[CONF_HOST], + CONF_USERNAME: config[CONF_USERNAME], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_SSL: config.get(CONF_SSL, DEFAULT_SSL), + CONF_VERIFY_SSL: config.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), + }, + ) + + if result["type"] is FlowResultType.ABORT: + reason = result["reason"] + if reason in ("invalid_auth", "cannot_connect"): + ir.async_create_issue( + hass, + DOMAIN, + f"yaml_import_{reason}", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.ERROR, + translation_key=f"yaml_import_{reason}", + translation_placeholders={"host": config[CONF_HOST]}, + ) + return True + + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "OpenWrt (luci)", + }, + ) + + return True + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LuciConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up device tracker for OpenWrt (luci) component.""" + coordinator = entry.runtime_data + + async_add_entities( + LuciScannerEntity(coordinator, mac, device) + for mac, device in coordinator.data.items() + ) + + +class LuciScannerEntity(CoordinatorEntity[LuciCoordinator], ScannerEntity): + """Representation of a device connected to an OpenWrt router.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: LuciCoordinator, + mac: str, + device: Any, + ) -> None: + """Initialize the scanner entity.""" + super().__init__(coordinator) + self._mac = mac + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{mac}" + self._attr_mac_address = mac + self._attr_hostname = device.hostname + self._attr_ip_address = device.ip + self._attr_name = device.hostname or mac + + @property @override - def scan_devices(self): - """Scan for new devices and return a list with found device IDs.""" - self._update_info() - - return [device.mac for device in self.last_results] + def unique_id(self) -> str | None: + """Return the unique ID of the entity.""" + return self._attr_unique_id + @property @override - def get_device_name(self, device): - """Return the name of the given device or None if we don't know.""" - return next( - (result.hostname for result in self.last_results if result.mac == device), - None, - ) + def is_connected(self) -> bool: + """Return true if the device is connected to the router.""" + return self._mac in self.coordinator.data + @callback @override - def get_extra_attributes(self, device): - """Get extra attributes of a device. - - Some known extra attributes that may be returned in the device tuple - include MAC address (mac), network device (dev), IP address - (ip), reachable status (reachable), associated router - (host), hostname if known (hostname) among others. - """ - if not ( - device := next( - (result for result in self.last_results if result.mac == device), None - ) - ): - return {} - return device._asdict() - - def _update_info(self): - """Check the Luci router for devices.""" - result = self.router.get_all_connected_devices(only_reachable=True) - - _LOGGER.debug("Luci get_all_connected_devices returned: %s", result) - - self.last_results = [ - device - for device in result - if not hasattr(self.router.router.owrt_version, "release") - or not self.router.router.owrt_version.release - or self.router.router.owrt_version.release[0] < 19 - or device.reachable - ] + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self._mac in self.coordinator.data: + device = self.coordinator.data[self._mac] + self._attr_hostname = device.hostname + self._attr_ip_address = device.ip + self._attr_name = device.hostname or self._mac + super()._handle_coordinator_update() diff --git a/homeassistant/components/luci/manifest.json b/homeassistant/components/luci/manifest.json index a8df2c63df4b27..11f2816457506e 100644 --- a/homeassistant/components/luci/manifest.json +++ b/homeassistant/components/luci/manifest.json @@ -2,7 +2,9 @@ "domain": "luci", "name": "OpenWrt (luci)", "codeowners": ["@mzdrale"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/luci", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["openwrt_luci_rpc"], "quality_scale": "legacy", diff --git a/homeassistant/components/luci/strings.json b/homeassistant/components/luci/strings.json new file mode 100644 index 00000000000000..d9600a9c9a25ae --- /dev/null +++ b/homeassistant/components/luci/strings.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + } + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + } + } + } + }, + "issues": { + "yaml_import_cannot_connect": { + "description": "The YAML configuration for OpenWrt (luci) at `{host}` could not be imported because the connection failed.\n\nPlease check that `{host}` is reachable, update your `configuration.yaml` if needed, and restart Home Assistant.", + "title": "YAML configuration import failed: cannot connect" + }, + "yaml_import_invalid_auth": { + "description": "The YAML configuration for OpenWrt (luci) at `{host}` could not be imported because the credentials are invalid.\n\nPlease update your `configuration.yaml` with valid credentials and restart Home Assistant.", + "title": "YAML configuration import failed: invalid authentication" + } + } +} diff --git a/homeassistant/components/matter/update.py b/homeassistant/components/matter/update.py index 776d895186a882..26c12da1677532 100644 --- a/homeassistant/components/matter/update.py +++ b/homeassistant/components/matter/update.py @@ -9,11 +9,11 @@ from matter_server.common.models import MatterSoftwareVersion, UpdateSource from homeassistant.components.update import ( - ATTR_LATEST_VERSION, UpdateDeviceClass, UpdateEntity, UpdateEntityDescription, UpdateEntityFeature, + UpdateEntityStateAttribute, ) from homeassistant.const import STATE_ON, Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback @@ -204,7 +204,9 @@ async def async_added_to_hass(self) -> None: await super().async_added_to_hass() if state := await self.async_get_last_state(): - self._attr_latest_version = state.attributes.get(ATTR_LATEST_VERSION) + self._attr_latest_version = state.attributes.get( + UpdateEntityStateAttribute.LATEST_VERSION + ) if (extra_data := await self.async_get_last_extra_data()) and ( matter_extra_data := MatterUpdateExtraStoredData.from_dict( diff --git a/homeassistant/components/mcp_server/http.py b/homeassistant/components/mcp_server/http.py index 4317bb3d5494bd..6f2857f0d9caa2 100644 --- a/homeassistant/components/mcp_server/http.py +++ b/homeassistant/components/mcp_server/http.py @@ -118,6 +118,11 @@ async def aclose(self) -> None: @asynccontextmanager async def create_streams() -> AsyncGenerator[Streams]: """Create a new pair of streams for MCP server communication.""" + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] + read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] + write_stream: MemoryObjectSendStream[SessionMessage] + write_stream_reader: MemoryObjectReceiveStream[SessionMessage] + read_stream_writer, read_stream = anyio.create_memory_object_stream(0) write_stream, write_stream_reader = anyio.create_memory_object_stream(0) streams = Streams( diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index d9e367be06d028..d0b576631bcc30 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -12,9 +12,28 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) from .const import DOMAIN +USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT, autocomplete="username") + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } +) + class FlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" @@ -69,9 +88,7 @@ async def async_step_user( if user_input is None: return self.async_show_form( step_id="user", - data_schema=vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} - ), + data_schema=USER_SCHEMA, ) return await self._create_client( username=user_input[CONF_USERNAME], password=user_input[CONF_PASSWORD] @@ -98,9 +115,7 @@ async def async_step_reauth_confirm( ) return self.async_show_form( step_id="reauth_confirm", - data_schema=vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} - ), + data_schema=USER_SCHEMA, errors=errors, ) @@ -180,7 +195,12 @@ async def async_step_reconfigure( step_id="reconfigure", data_schema=vol.Schema( { - vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), } ), errors=errors, diff --git a/homeassistant/components/melcloud_home/coordinator.py b/homeassistant/components/melcloud_home/coordinator.py index 1f9a745ac30f25..f3d4f8ddb4cd9b 100644 --- a/homeassistant/components/melcloud_home/coordinator.py +++ b/homeassistant/components/melcloud_home/coordinator.py @@ -17,6 +17,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.dt import utcnow from .const import DOMAIN @@ -50,6 +51,8 @@ def __init__( self.client = client self.ata_units: dict[str, ATAUnit] = {} self.atw_units: dict[str, ATWUnit] = {} + self.ata_energy: dict[str, float | None] = {} + self.atw_energy: dict[str, float | None] = {} self.known_ata: set[str] = set() self.known_atw: set[str] = set() self.new_ata_callbacks: list[Callable[[list[ATAUnit]], None]] = [] @@ -60,7 +63,7 @@ def _notify_new_units(self, data: UserContext) -> None: current_ata = [ unit for building in data.buildings for unit in building.air_to_air_units ] - self.ata_units = {unit.id: unit for unit in current_ata} + current_ata_ids = {unit.id for unit in current_ata} self.known_ata &= current_ata_ids new_ata_ids = current_ata_ids - self.known_ata @@ -74,7 +77,7 @@ def _notify_new_units(self, data: UserContext) -> None: current_atw_units = [ unit for building in data.buildings for unit in building.air_to_water_units ] - self.atw_units = {unit.id: unit for unit in current_atw_units} + current_atw_ids = {unit.id for unit in current_atw_units} self.known_atw &= current_atw_ids new_atw_ids = current_atw_ids - self.known_atw @@ -108,6 +111,39 @@ async def _async_update_data(self) -> UserContext: """Fetch data from the MELCloud Home API.""" try: data = await self.client.get_context() + + start_of_month = utcnow().replace( + day=1, hour=0, minute=0, second=0, microsecond=0 + ) + for building in data.buildings: + for ata_unit in building.air_to_air_units: + self.ata_units[ata_unit.id] = ata_unit + if ( + ata_unit.capabilities + and ata_unit.capabilities.has_energy_consumed_meter + ): + energy = await self.client.get_energy_telemetry( + ata_unit.id, + from_dt=start_of_month, + to_dt=utcnow(), + ) + self.ata_energy[ata_unit.id] = sum( + float(e.value) for e in energy + ) + for atw_unit in building.air_to_water_units: + self.atw_units[atw_unit.id] = atw_unit + if ( + atw_unit.capabilities + and atw_unit.capabilities.has_energy_consumed_meter + ): + energy = await self.client.get_energy_telemetry( + atw_unit.id, + from_dt=start_of_month, + to_dt=utcnow(), + ) + self.atw_energy[atw_unit.id] = sum( + float(e.value) for e in energy + ) except MelCloudHomeAuthenticationError as err: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/melcloud_home/sensor.py b/homeassistant/components/melcloud_home/sensor.py index 907c7033b56ff8..32ab8932239772 100644 --- a/homeassistant/components/melcloud_home/sensor.py +++ b/homeassistant/components/melcloud_home/sensor.py @@ -2,6 +2,7 @@ from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime from typing import override from aiomelcloudhome import ATAUnit, ATWUnit @@ -16,10 +17,12 @@ from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, + UnitOfEnergy, UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.dt import utcnow from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity @@ -31,15 +34,16 @@ class ATASensorEntityDescription(SensorEntityDescription): """Class to hold MELCloud Home ATA sensor description.""" - value_fn: Callable[[ATAUnit], StateType] + value_fn: Callable[[ATAUnit, MelCloudHomeCoordinator], StateType] + exists_fn: Callable[[ATAUnit], bool] = lambda _: True @dataclass(frozen=True, kw_only=True) class ATWSensorEntityDescription(SensorEntityDescription): """Class to hold MELCloud Home ATW sensor description.""" - value_fn: Callable[[ATWUnit], StateType] - exists_fn: Callable[[ATWUnit], bool] = lambda unit: True + value_fn: Callable[[ATWUnit, MelCloudHomeCoordinator], StateType] + exists_fn: Callable[[ATWUnit], bool] = lambda _: True ATA_SENSORS: tuple[ATASensorEntityDescription, ...] = ( @@ -50,7 +54,7 @@ class ATWSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, suggested_display_precision=1, - value_fn=lambda unit: unit.room_temperature, + value_fn=lambda unit, _: unit.room_temperature, ), ATASensorEntityDescription( key="rssi", @@ -59,7 +63,19 @@ class ATWSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda unit: unit.rssi, + value_fn=lambda unit, _: unit.rssi, + ), + ATASensorEntityDescription( + key="energy_consumed", + translation_key="energy_consumed", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_fn=lambda unit, coordinator: coordinator.ata_energy.get(unit.id), + exists_fn=lambda unit: bool( + unit.capabilities and unit.capabilities.has_energy_consumed_meter + ), ), ) @@ -71,7 +87,7 @@ class ATWSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, suggested_display_precision=1, - value_fn=lambda unit: unit.room_temperature_zone1, + value_fn=lambda unit, _: unit.room_temperature_zone1, ), ATWSensorEntityDescription( key="room_temperature_zone_2", @@ -80,7 +96,7 @@ class ATWSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, suggested_display_precision=1, - value_fn=lambda unit: unit.room_temperature_zone2, + value_fn=lambda unit, _: unit.room_temperature_zone2, exists_fn=lambda unit: bool( (unit.capabilities and unit.capabilities.has_zone2) or (unit.capabilities is None and unit.has_zone2) @@ -93,7 +109,7 @@ class ATWSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, suggested_display_precision=1, - value_fn=lambda unit: unit.tank_water_temperature, + value_fn=lambda unit, _: unit.tank_water_temperature, ), ATWSensorEntityDescription( key="rssi", @@ -102,7 +118,19 @@ class ATWSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda unit: unit.rssi, + value_fn=lambda unit, _: unit.rssi, + ), + ATWSensorEntityDescription( + key="energy_consumed", + translation_key="energy_consumed", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_fn=lambda unit, coordinator: coordinator.atw_energy.get(unit.id), + exists_fn=lambda unit: bool( + unit.capabilities and unit.capabilities.has_energy_consumed_meter + ), ), ) @@ -120,6 +148,7 @@ def _async_add_new_ata_units(units: list[ATAUnit]) -> None: ATASensor(coordinator, entity_description, unit) for entity_description in ATA_SENSORS for unit in units + if entity_description.exists_fn(unit) ) def _async_add_new_atw_units(units: list[ATWUnit]) -> None: @@ -157,7 +186,14 @@ def __init__( @override def native_value(self) -> StateType: """Return the state of the sensor.""" - return self.entity_description.value_fn(self.unit) + return self.entity_description.value_fn(self.unit, self.coordinator) + + @property + def last_reset(self) -> datetime | None: + """Return start of month for TOTAL energy sensors.""" + if self.entity_description.state_class == SensorStateClass.TOTAL: + return utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0) + return None class ATWSensor(MelCloudHomeATWUnitEntity, SensorEntity): @@ -180,4 +216,11 @@ def __init__( @override def native_value(self) -> StateType: """Return the state of the sensor.""" - return self.entity_description.value_fn(self.unit) + return self.entity_description.value_fn(self.unit, self.coordinator) + + @property + def last_reset(self) -> datetime | None: + """Return start of month for TOTAL energy sensors.""" + if self.entity_description.state_class == SensorStateClass.TOTAL: + return utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0) + return None diff --git a/homeassistant/components/melcloud_home/strings.json b/homeassistant/components/melcloud_home/strings.json index b5eadcc104cfd7..a56154c757ffb7 100644 --- a/homeassistant/components/melcloud_home/strings.json +++ b/homeassistant/components/melcloud_home/strings.json @@ -97,6 +97,9 @@ } }, "sensor": { + "energy_consumed": { + "name": "Energy consumed (monthly)" + }, "room_temperature": { "name": "Room temperature" }, diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 402bf30d7a5499..8d2590d442fadc 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -21,6 +21,7 @@ SWING_VERTICAL, ClimateEntity, ClimateEntityFeature, + ClimateEntityStateAttribute, HVACAction, HVACMode, ) @@ -314,8 +315,10 @@ async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await self.async_base_added_to_hass() state = await self.async_get_last_state() - if state and state.attributes.get(ATTR_TEMPERATURE): - self._attr_target_temperature = float(state.attributes[ATTR_TEMPERATURE]) + if state and state.attributes.get(ClimateEntityStateAttribute.TEMPERATURE): + self._attr_target_temperature = float( + state.attributes[ClimateEntityStateAttribute.TEMPERATURE] + ) @override async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index a829e6432e2f65..39c1c60754afb2 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -7,6 +7,7 @@ ATTR_COLOR_TEMP_KELVIN, ColorMode, LightEntity, + LightEntityStateAttribute, ) from homeassistant.const import CONF_LIGHTS, CONF_NAME from homeassistant.core import HomeAssistant @@ -79,10 +80,16 @@ async def async_added_to_hass(self) -> None: if (state := await self.async_get_last_state()) is None: return - if (brightness := state.attributes.get(ATTR_BRIGHTNESS)) is not None: + if ( + brightness := state.attributes.get(LightEntityStateAttribute.BRIGHTNESS) + ) is not None: self._attr_brightness = brightness - if (color_temp := state.attributes.get(ATTR_COLOR_TEMP_KELVIN)) is not None: + if ( + color_temp := state.attributes.get( + LightEntityStateAttribute.COLOR_TEMP_KELVIN + ) + ) is not None: self._attr_color_temp_kelvin = color_temp @staticmethod diff --git a/homeassistant/components/monarch_money/config_flow.py b/homeassistant/components/monarch_money/config_flow.py index a0d4d714bc8c47..df2d01d96b704d 100644 --- a/homeassistant/components/monarch_money/config_flow.py +++ b/homeassistant/components/monarch_money/config_flow.py @@ -68,9 +68,8 @@ async def validate_login( LOGGER.debug("Attempting to authenticate with MFA code") try: await monarch_client.multi_factor_authenticate(email, password, mfa_code) - except KeyError as err: - # A bug in the backing lib that I don't control - # throws a KeyError if the MFA code is wrong + except (KeyError, RequireMFAException, LoginFailedException) as err: + # Backing library MFA failures can surface as a KeyError or auth error. LOGGER.debug("Bad MFA Code") raise BadMFA from err else: diff --git a/homeassistant/components/monarch_money/manifest.json b/homeassistant/components/monarch_money/manifest.json index 7ada72badb1a4c..015418de814e3d 100644 --- a/homeassistant/components/monarch_money/manifest.json +++ b/homeassistant/components/monarch_money/manifest.json @@ -1,10 +1,10 @@ { "domain": "monarch_money", "name": "Monarch Money", - "codeowners": ["@jeeftor"], + "codeowners": ["@jeeftor", "@bradleyseanf"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/monarchmoney", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["typedmonarchmoney==0.7.0"] + "requirements": ["monarchmoneycommunity==1.5.1"] } diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index b1d591190ff063..183732c5ea1c48 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -30,7 +30,7 @@ from . import api from .const import DOMAIN, PLATFORMS -from .data_handler import NetatmoConfigEntry, NetatmoDataHandler +from .coordinator import NetatmoConfigEntry, NetatmoDataHandler from .services import async_setup_services from .webhook import async_register_webhook, async_unregister_webhook diff --git a/homeassistant/components/netatmo/binary_sensor.py b/homeassistant/components/netatmo/binary_sensor.py index d0dc4348690a3b..f230a33c8e8e7c 100644 --- a/homeassistant/components/netatmo/binary_sensor.py +++ b/homeassistant/components/netatmo/binary_sensor.py @@ -37,7 +37,7 @@ NETATMO_CREATE_OPENING_BINARY_SENSOR, NETATMO_CREATE_WEATHER_BINARY_SENSOR, ) -from .data_handler import SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice +from .coordinator import SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity, NetatmoWeatherModuleEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/netatmo/button.py b/homeassistant/components/netatmo/button.py index dd0895a28157ef..8c7b536bf8fb63 100644 --- a/homeassistant/components/netatmo/button.py +++ b/homeassistant/components/netatmo/button.py @@ -11,7 +11,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_BUTTON -from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice +from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity from .helper import device_type_to_str diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 51c14e0b69d322..692149f34a2fce 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -37,7 +37,7 @@ SERVICE_SET_PERSONS_HOME, WEBHOOK_PUSH_TYPE, ) -from .data_handler import EVENT, HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice +from .coordinator import EVENT, HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity from .helper import device_type_to_str diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 7c60ea7f2f0f13..4e50972286c50a 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -50,7 +50,7 @@ SERVICE_SET_TEMPERATURE_WITH_END_DATETIME, SERVICE_SET_TEMPERATURE_WITH_TIME_PERIOD, ) -from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoRoom +from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoRoom from .entity import NetatmoRoomEntity from .helper import device_type_to_str diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index 0883cf89d2ac49..0e4c74a6d0005c 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -24,7 +24,7 @@ CONF_WEATHER_AREAS, DOMAIN, ) -from .data_handler import NetatmoConfigEntry +from .coordinator import NetatmoConfigEntry _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/coordinator.py similarity index 100% rename from homeassistant/components/netatmo/data_handler.py rename to homeassistant/components/netatmo/coordinator.py diff --git a/homeassistant/components/netatmo/cover.py b/homeassistant/components/netatmo/cover.py index ba244724f73601..2244de089830ce 100644 --- a/homeassistant/components/netatmo/cover.py +++ b/homeassistant/components/netatmo/cover.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_COVER -from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice +from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity from .helper import device_type_to_str diff --git a/homeassistant/components/netatmo/diagnostics.py b/homeassistant/components/netatmo/diagnostics.py index a3b4f032714df7..08bbd0a1ff89f2 100644 --- a/homeassistant/components/netatmo/diagnostics.py +++ b/homeassistant/components/netatmo/diagnostics.py @@ -5,7 +5,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant -from .data_handler import ACCOUNT, NetatmoConfigEntry +from .coordinator import ACCOUNT, NetatmoConfigEntry TO_REDACT = { "access_token", diff --git a/homeassistant/components/netatmo/entity.py b/homeassistant/components/netatmo/entity.py index d74658eb0727a8..c1f386b888dcd5 100644 --- a/homeassistant/components/netatmo/entity.py +++ b/homeassistant/components/netatmo/entity.py @@ -20,7 +20,7 @@ DOMAIN, SIGNAL_NAME, ) -from .data_handler import PUBLIC, NetatmoDataHandler, NetatmoDevice, NetatmoRoom +from .coordinator import PUBLIC, NetatmoDataHandler, NetatmoDevice, NetatmoRoom class NetatmoBaseEntity(Entity): diff --git a/homeassistant/components/netatmo/fan.py b/homeassistant/components/netatmo/fan.py index 3dacf5b3fe5ffa..c5e01c5904a64f 100644 --- a/homeassistant/components/netatmo/fan.py +++ b/homeassistant/components/netatmo/fan.py @@ -11,7 +11,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_FAN -from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice +from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity from .helper import device_type_to_str diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index 3bbc6f320c9e6b..2de7dbcf7ee6a8 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -19,7 +19,7 @@ NETATMO_CREATE_CAMERA_LIGHT, NETATMO_CREATE_LIGHT, ) -from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice +from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/netatmo/media_source.py b/homeassistant/components/netatmo/media_source.py index 5bfaf25f4739fe..2006e43a94a225 100644 --- a/homeassistant/components/netatmo/media_source.py +++ b/homeassistant/components/netatmo/media_source.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant, callback from .const import DOMAIN, MANUFACTURER -from .data_handler import NetatmoConfigEntry, NetatmoDataHandler, async_get_loaded_entry +from .coordinator import NetatmoConfigEntry, NetatmoDataHandler, async_get_loaded_entry _LOGGER = logging.getLogger(__name__) MIME_TYPE = "application/x-mpegURL" diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py index d4d746b62fbd7f..32c9fe74ee612e 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -16,7 +16,7 @@ MANUFACTURER, NETATMO_CREATE_SELECT, ) -from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoHome +from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoHome from .entity import NetatmoBaseEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 24bc7e149bd2c3..2b1f9bc9059310 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -53,7 +53,7 @@ NETATMO_CREATE_WEATHER_SENSOR, SIGNAL_NAME, ) -from .data_handler import ( +from .coordinator import ( HOME, PUBLIC, NetatmoConfigEntry, diff --git a/homeassistant/components/netatmo/services.py b/homeassistant/components/netatmo/services.py index b3ca63824a7404..1265b70fa6653c 100644 --- a/homeassistant/components/netatmo/services.py +++ b/homeassistant/components/netatmo/services.py @@ -5,7 +5,7 @@ from homeassistant.helpers import issue_registry as ir from .const import DOMAIN -from .data_handler import NetatmoConfigEntry, async_get_loaded_entry +from .coordinator import NetatmoConfigEntry, async_get_loaded_entry from .webhook import async_register_webhook, async_unregister_webhook SERVICE_REGISTER_WEBHOOK = "register_webhook" diff --git a/homeassistant/components/netatmo/switch.py b/homeassistant/components/netatmo/switch.py index 7bb3f83387ef16..351d6d005fed4b 100644 --- a/homeassistant/components/netatmo/switch.py +++ b/homeassistant/components/netatmo/switch.py @@ -11,7 +11,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_SWITCH -from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice +from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity from .helper import device_type_to_str diff --git a/homeassistant/components/netatmo/webhook.py b/homeassistant/components/netatmo/webhook.py index 1670f2c2657c24..b5cd935abdcbb3 100644 --- a/homeassistant/components/netatmo/webhook.py +++ b/homeassistant/components/netatmo/webhook.py @@ -37,7 +37,7 @@ WEBHOOK_DEACTIVATION, WEBHOOK_PUSH_TYPE, ) -from .data_handler import NetatmoConfigEntry, NetatmoDataHandler +from .coordinator import NetatmoConfigEntry, NetatmoDataHandler _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/novy_cooker_hood/fan.py b/homeassistant/components/novy_cooker_hood/fan.py index f59281e6c7e351..f2a8b0e6f69430 100644 --- a/homeassistant/components/novy_cooker_hood/fan.py +++ b/homeassistant/components/novy_cooker_hood/fan.py @@ -6,7 +6,11 @@ from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton from rf_protocols.commands.novy import NovyCookerHoodCommand -from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntity, FanEntityFeature +from homeassistant.components.fan import ( + FanEntity, + FanEntityFeature, + FanEntityStateAttribute, +) from homeassistant.components.radio_frequency import async_send_command from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CODE @@ -74,7 +78,7 @@ async def async_added_to_hass(self) -> None: last = await self.async_get_last_state() if last is None: return - last_pct = last.attributes.get(ATTR_PERCENTAGE) + last_pct = last.attributes.get(FanEntityStateAttribute.PERCENTAGE) if isinstance(last_pct, (int, float)) and last_pct > 0: self._level = math.ceil(percentage_to_ranged_value(_SPEED_RANGE, last_pct)) diff --git a/homeassistant/components/open_meteo/coordinator.py b/homeassistant/components/open_meteo/coordinator.py index e4834994a12beb..7e8066c1d748df 100644 --- a/homeassistant/components/open_meteo/coordinator.py +++ b/homeassistant/components/open_meteo/coordinator.py @@ -13,8 +13,9 @@ WindSpeedUnit, ) +from homeassistant.components.zone import ZoneEntityStateAttribute from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ZONE +from homeassistant.const import CONF_ZONE from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -49,8 +50,8 @@ async def _async_update_data(self) -> Forecast: try: return await self.open_meteo.forecast( - latitude=zone.attributes[ATTR_LATITUDE], - longitude=zone.attributes[ATTR_LONGITUDE], + latitude=zone.attributes[ZoneEntityStateAttribute.LATITUDE], + longitude=zone.attributes[ZoneEntityStateAttribute.LONGITUDE], current_weather=True, daily=[ DailyParameters.PRECIPITATION_SUM, diff --git a/homeassistant/components/pilight/entity.py b/homeassistant/components/pilight/entity.py index 5d4196537880b4..6b49adbfcf6bb4 100644 --- a/homeassistant/components/pilight/entity.py +++ b/homeassistant/components/pilight/entity.py @@ -4,6 +4,7 @@ import voluptuous as vol +from homeassistant.components.light import LightEntityStateAttribute from homeassistant.const import ( CONF_ID, CONF_NAME, @@ -97,7 +98,9 @@ async def async_added_to_hass(self) -> None: await super().async_added_to_hass() if state := await self.async_get_last_state(): self._attr_is_on = state.state == STATE_ON - self._brightness = state.attributes.get("brightness") + self._brightness = state.attributes.get( + LightEntityStateAttribute.BRIGHTNESS + ) def _handle_code(self, call): """Check if received code by the pilight-daemon. diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index 2904d9320f1070..d1ba2dede5d703 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -15,54 +15,32 @@ from homeassistant import core as hacore from homeassistant.components.alarm_control_panel import AlarmControlPanelState from homeassistant.components.climate import ( - ATTR_CURRENT_TEMPERATURE, - ATTR_FAN_MODE, - ATTR_FAN_MODES, - ATTR_HVAC_ACTION, - ATTR_HVAC_MODES, - ATTR_TARGET_TEMP_HIGH, - ATTR_TARGET_TEMP_LOW, + ClimateEntityCapabilityAttribute, + ClimateEntityStateAttribute, HVACAction, ) -from homeassistant.components.cover import ( - ATTR_CURRENT_POSITION, - ATTR_CURRENT_TILT_POSITION, -) +from homeassistant.components.cover import CoverEntityStateAttribute from homeassistant.components.fan import ( - ATTR_DIRECTION, - ATTR_OSCILLATING, - ATTR_PERCENTAGE, - ATTR_PRESET_MODE, - ATTR_PRESET_MODES, DIRECTION_FORWARD, DIRECTION_REVERSE, + FanEntityCapabilityAttribute, + FanEntityStateAttribute, ) from homeassistant.components.http import KEY_HASS, HomeAssistantView -from homeassistant.components.humidifier import ATTR_AVAILABLE_MODES, ATTR_HUMIDITY -from homeassistant.components.light import ATTR_BRIGHTNESS +from homeassistant.components.humidifier import ( + HumidifierEntityCapabilityAttribute, + HumidifierEntityStateAttribute, +) +from homeassistant.components.light import LightEntityStateAttribute from homeassistant.components.sensor import SensorDeviceClass - -# Alias water_heater constants to avoid name clashes with -# similarly named climate constants from homeassistant.components.water_heater import ( - ATTR_AWAY_MODE as WATER_HEATER_ATTR_AWAY_MODE, - ATTR_CURRENT_TEMPERATURE as WATER_HEATER_ATTR_CURRENT_TEMPERATURE, - ATTR_MAX_TEMP as WATER_HEATER_ATTR_MAX_TEMP, - ATTR_MIN_TEMP as WATER_HEATER_ATTR_MIN_TEMP, - ATTR_OPERATION_LIST as WATER_HEATER_ATTR_OPERATION_LIST, - ATTR_OPERATION_MODE as WATER_HEATER_ATTR_OPERATION_MODE, - ATTR_TARGET_TEMP_HIGH as WATER_HEATER_ATTR_TARGET_TEMP_HIGH, - ATTR_TARGET_TEMP_LOW as WATER_HEATER_ATTR_TARGET_TEMP_LOW, + WaterHeaterCapabilityAttribute, + WaterHeaterStateAttribute, ) from homeassistant.const import ( ATTR_BATTERY_LEVEL, - ATTR_DEVICE_CLASS, - ATTR_FRIENDLY_NAME, ATTR_LATITUDE, ATTR_LONGITUDE, - ATTR_MODE, - ATTR_TEMPERATURE, - ATTR_UNIT_OF_MEASUREMENT, CONTENT_TYPE_TEXT_PLAIN, EVENT_STATE_CHANGED, PERCENTAGE, @@ -73,6 +51,7 @@ STATE_OPENING, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, UnitOfLength, UnitOfTemperature, ) @@ -293,8 +272,8 @@ def handle_state_changed_event(self, event: Event[EventStateChangedData]) -> Non if ( old_state := event.data.get("old_state") ) is not None and old_state.attributes.get( - ATTR_FRIENDLY_NAME - ) != state.attributes.get(ATTR_FRIENDLY_NAME): + EntityStateAttribute.FRIENDLY_NAME + ) != state.attributes.get(EntityStateAttribute.FRIENDLY_NAME): self._remove_labelsets(old_state.entity_id) self.handle_state(state) @@ -569,7 +548,10 @@ def _sanitize_metric_name(metric: str) -> str: def state_as_number(state: State) -> float | None: """Return state as a float, or None if state cannot be converted.""" try: - if state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP: + if ( + state.attributes.get(EntityStateAttribute.DEVICE_CLASS) + == SensorDeviceClass.TIMESTAMP + ): value = as_timestamp(state.state) else: value = state_helper.state_as_number(state) @@ -588,7 +570,7 @@ def _labels( labels = { "entity": state.entity_id, "domain": state.domain, - "friendly_name": state.attributes.get(ATTR_FRIENDLY_NAME), + "friendly_name": state.attributes.get(EntityStateAttribute.FRIENDLY_NAME), } if not labels.keys().isdisjoint(extra_labels.keys()): conflicting_keys = labels.keys() & extra_labels.keys() @@ -731,7 +713,9 @@ def _numeric_metric(self, state: State, domain: str, title: str) -> None: if (value := self.state_as_number(state)) is None: return - if unit := self._unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)): + if unit := self._unit_string( + state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + ): metric = self._metric( f"{domain}_state_{unit}", prometheus_client.Gauge, @@ -747,7 +731,7 @@ def _numeric_metric(self, state: State, domain: str, title: str) -> None: ) if ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) == UnitOfTemperature.FAHRENHEIT ): value = TemperatureConverter.convert( @@ -777,7 +761,7 @@ def _handle_person(self, state: State) -> None: def _handle_geo_location(self, state: State) -> None: labels = self._labels(state, {"source": state.attributes.get("source", "")}) if (value := self.state_as_number(state)) is not None: - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) if unit is not None: value = DistanceConverter.convert(value, unit, UnitOfLength.METERS) self._metric( @@ -815,13 +799,13 @@ def _handle_cover(self, state: State) -> None: ) self._float_metric( state, - ATTR_CURRENT_POSITION, + CoverEntityStateAttribute.CURRENT_POSITION, "cover_position", "Position of the cover (0-100)", ) self._float_metric( state, - ATTR_CURRENT_TILT_POSITION, + CoverEntityStateAttribute.CURRENT_TILT_POSITION, "cover_tilt_position", "Tilt Position of the cover (0-100)", ) @@ -830,7 +814,7 @@ def _handle_light(self, state: State) -> None: if (value := self.state_as_number(state)) is None: return - brightness = state.attributes.get(ATTR_BRIGHTNESS) + brightness = state.attributes.get(LightEntityStateAttribute.BRIGHTNESS) if state.state == STATE_ON and brightness is not None: value = float(brightness) / 255.0 value = value * 100 @@ -845,25 +829,25 @@ def _handle_light(self, state: State) -> None: def _handle_climate(self, state: State) -> None: self._temperature_metric( state, - ATTR_TEMPERATURE, + ClimateEntityStateAttribute.TEMPERATURE, "climate_target_temperature_celsius", "Target temperature in degrees Celsius", ) self._temperature_metric( state, - ATTR_TARGET_TEMP_HIGH, + ClimateEntityStateAttribute.TARGET_TEMP_HIGH, "climate_target_temperature_high_celsius", "Target high temperature in degrees Celsius", ) self._temperature_metric( state, - ATTR_TARGET_TEMP_LOW, + ClimateEntityStateAttribute.TARGET_TEMP_LOW, "climate_target_temperature_low_celsius", "Target low temperature in degrees Celsius", ) self._temperature_metric( state, - ATTR_CURRENT_TEMPERATURE, + ClimateEntityStateAttribute.CURRENT_TEMPERATURE, "climate_current_temperature_celsius", "Current temperature in degrees Celsius", ) @@ -871,7 +855,7 @@ def _handle_climate(self, state: State) -> None: self._enum_metric( state, ( - (attr := state.attributes.get(ATTR_HVAC_ACTION)) + (attr := state.attributes.get(ClimateEntityStateAttribute.HVAC_ACTION)) and getattr(attr, "value", attr) ), [action.value for action in HVACAction], @@ -882,23 +866,23 @@ def _handle_climate(self, state: State) -> None: self._enum_metric( state, state.state, - state.attributes.get(ATTR_HVAC_MODES), + state.attributes.get(ClimateEntityCapabilityAttribute.HVAC_MODES), "climate_mode", "HVAC mode", "mode", ) self._enum_metric( state, - state.attributes.get(ATTR_PRESET_MODE), - state.attributes.get(ATTR_PRESET_MODES), + state.attributes.get(ClimateEntityStateAttribute.PRESET_MODE), + state.attributes.get(ClimateEntityCapabilityAttribute.PRESET_MODES), "climate_preset_mode", "Preset mode enum", "mode", ) self._enum_metric( state, - state.attributes.get(ATTR_FAN_MODE), - state.attributes.get(ATTR_FAN_MODES), + state.attributes.get(ClimateEntityStateAttribute.FAN_MODE), + state.attributes.get(ClimateEntityCapabilityAttribute.FAN_MODES), "climate_fan_mode", "Fan mode enum", "mode", @@ -909,15 +893,15 @@ def _handle_humidifier(self, state: State) -> None: self._float_metric( state, - ATTR_HUMIDITY, + HumidifierEntityStateAttribute.HUMIDITY, "humidifier_target_humidity_percent", "Target Relative Humidity", ) self._enum_metric( state, - state.attributes.get(ATTR_MODE), - state.attributes.get(ATTR_AVAILABLE_MODES), + state.attributes.get(HumidifierEntityStateAttribute.MODE), + state.attributes.get(HumidifierEntityCapabilityAttribute.AVAILABLE_MODES), "humidifier_mode", "Humidifier Mode", "mode", @@ -927,44 +911,45 @@ def _handle_water_heater(self, state: State) -> None: # Temperatures self._temperature_metric( state, - ATTR_TEMPERATURE, + WaterHeaterStateAttribute.TEMPERATURE, "water_heater_temperature_celsius", "Target temperature in degrees Celsius", ) self._temperature_metric( state, - WATER_HEATER_ATTR_CURRENT_TEMPERATURE, + WaterHeaterStateAttribute.CURRENT_TEMPERATURE, "water_heater_current_temperature_celsius", "Current temperature in degrees Celsius", ) self._temperature_metric( state, - WATER_HEATER_ATTR_TARGET_TEMP_HIGH, + WaterHeaterStateAttribute.TARGET_TEMP_HIGH, "water_heater_target_temperature_high_celsius", "Target high temperature in degrees Celsius", ) self._temperature_metric( state, - WATER_HEATER_ATTR_TARGET_TEMP_LOW, + WaterHeaterStateAttribute.TARGET_TEMP_LOW, "water_heater_target_temperature_low_celsius", "Target low temperature in degrees Celsius", ) self._temperature_metric( state, - WATER_HEATER_ATTR_MIN_TEMP, + WaterHeaterCapabilityAttribute.MIN_TEMP, "water_heater_min_temperature_celsius", "Minimum allowed temperature in degrees Celsius", ) self._temperature_metric( state, - WATER_HEATER_ATTR_MAX_TEMP, + WaterHeaterCapabilityAttribute.MAX_TEMP, "water_heater_max_temperature_celsius", "Maximum allowed temperature in degrees Celsius", ) self._enum_metric( state, - state.attributes.get(WATER_HEATER_ATTR_OPERATION_MODE) or state.state, - state.attributes.get(WATER_HEATER_ATTR_OPERATION_LIST), + state.attributes.get(WaterHeaterStateAttribute.OPERATION_MODE) + or state.state, + state.attributes.get(WaterHeaterCapabilityAttribute.OPERATION_LIST), "water_heater_operation_mode", "Water heater operation mode", "mode", @@ -973,7 +958,7 @@ def _handle_water_heater(self, state: State) -> None: # Away mode bool self._bool_metric( state, - WATER_HEATER_ATTR_AWAY_MODE, + WaterHeaterStateAttribute.AWAY_MODE, "water_heater_away_mode", "Whether away mode is on (0/1)", {STATE_ON}, @@ -986,29 +971,32 @@ def _handle_switch(self, state: State) -> None: def _handle_fan(self, state: State) -> None: self._numeric_metric(state, "fan", "fan") self._float_metric( - state, ATTR_PERCENTAGE, "fan_speed_percent", "Fan speed percent (0-100)" + state, + FanEntityStateAttribute.PERCENTAGE, + "fan_speed_percent", + "Fan speed percent (0-100)", ) self._bool_metric( state, - ATTR_OSCILLATING, + FanEntityStateAttribute.OSCILLATING, "fan_is_oscillating", "Whether the fan is oscillating (0/1)", ) self._enum_metric( state, - state.attributes.get(ATTR_PRESET_MODE), - state.attributes.get(ATTR_PRESET_MODES), + state.attributes.get(FanEntityStateAttribute.PRESET_MODE), + state.attributes.get(FanEntityCapabilityAttribute.PRESET_MODES), "fan_preset_mode", "Fan preset mode enum", "mode", ) - fan_direction = state.attributes.get(ATTR_DIRECTION) + fan_direction = state.attributes.get(FanEntityStateAttribute.DIRECTION) if fan_direction in {DIRECTION_FORWARD, DIRECTION_REVERSE}: self._bool_metric( state, - ATTR_DIRECTION, + FanEntityStateAttribute.DIRECTION, "fan_direction_reversed", "Fan direction reversed (bool)", {DIRECTION_REVERSE}, @@ -1050,7 +1038,9 @@ def _handle_alarm_control_panel(self, state: State) -> None: ) def _handle_sensor(self, state: State) -> None: - unit = self._unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) + unit = self._unit_string( + state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + ) for metric_handler in self._sensor_metric_handlers: metric = metric_handler(state, unit) @@ -1063,7 +1053,7 @@ def _handle_sensor(self, state: State) -> None: documentation = f"Sensor data measured in {unit}" if ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) == UnitOfTemperature.FAHRENHEIT ): value = TemperatureConverter.convert( @@ -1085,7 +1075,7 @@ def _sensor_default_metric(self, state: State, unit: str | None) -> str | None: @staticmethod def _sensor_attribute_metric(state: State, unit: str | None) -> str | None: """Get metric based on device class attribute.""" - metric = state.attributes.get(ATTR_DEVICE_CLASS) + metric = state.attributes.get(EntityStateAttribute.DEVICE_CLASS) if metric is not None: return f"sensor_{metric}_{unit}" return None @@ -1096,7 +1086,7 @@ def _sensor_timestamp_metric(state: State, unit: str | None) -> str | None: These have no unit of measurement attribute. """ - metric = state.attributes.get(ATTR_DEVICE_CLASS) + metric = state.attributes.get(EntityStateAttribute.DEVICE_CLASS) if metric == SensorDeviceClass.TIMESTAMP: return f"sensor_{metric}_seconds" return None diff --git a/homeassistant/components/rflink/light.py b/homeassistant/components/rflink/light.py index 58dc85f1fc12ab..dd15a21e5bcc63 100644 --- a/homeassistant/components/rflink/light.py +++ b/homeassistant/components/rflink/light.py @@ -12,6 +12,7 @@ PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA, ColorMode, LightEntity, + LightEntityStateAttribute, ) from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant @@ -215,10 +216,13 @@ async def async_added_to_hass(self) -> None: old_state = await self.async_get_last_state() if ( old_state is not None - and old_state.attributes.get(ATTR_BRIGHTNESS) is not None + and old_state.attributes.get(LightEntityStateAttribute.BRIGHTNESS) + is not None ): # restore also brightness in dimmables devices - self._brightness = int(old_state.attributes[ATTR_BRIGHTNESS]) + self._brightness = int( + old_state.attributes[LightEntityStateAttribute.BRIGHTNESS] + ) @override async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/rfxtrx/light.py b/homeassistant/components/rfxtrx/light.py index 77316c8d1323bb..99af025f1c0a12 100644 --- a/homeassistant/components/rfxtrx/light.py +++ b/homeassistant/components/rfxtrx/light.py @@ -5,7 +5,12 @@ import RFXtrx as rfxtrxmod -from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ColorMode, + LightEntity, + LightEntityStateAttribute, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant, callback @@ -70,7 +75,9 @@ async def async_added_to_hass(self) -> None: old_state = await self.async_get_last_state() if old_state is not None: self._attr_is_on = old_state.state == STATE_ON - if brightness := old_state.attributes.get(ATTR_BRIGHTNESS): + if brightness := old_state.attributes.get( + LightEntityStateAttribute.BRIGHTNESS + ): self._attr_brightness = int(brightness) @override diff --git a/homeassistant/components/schedule/trigger.py b/homeassistant/components/schedule/trigger.py index dba6c29b99bd1a..f637b60433e2e3 100644 --- a/homeassistant/components/schedule/trigger.py +++ b/homeassistant/components/schedule/trigger.py @@ -11,7 +11,7 @@ make_entity_target_state_trigger, ) -from .const import ATTR_NEXT_EVENT, DOMAIN +from .const import DOMAIN, ScheduleEntityStateAttribute class ScheduleBackToBackTrigger(EntityTransitionTriggerBase): @@ -24,8 +24,10 @@ class ScheduleBackToBackTrigger(EntityTransitionTriggerBase): @override def is_valid_transition(self, from_state: State, to_state: State) -> bool: """Check that the origin matches and the next event changed.""" - from_next_event = from_state.attributes.get(ATTR_NEXT_EVENT) - to_next_event = to_state.attributes.get(ATTR_NEXT_EVENT) + from_next_event = from_state.attributes.get( + ScheduleEntityStateAttribute.NEXT_EVENT + ) + to_next_event = to_state.attributes.get(ScheduleEntityStateAttribute.NEXT_EVENT) return ( from_state.state in self._from_states and from_next_event != to_next_event diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index bb40f3cfe2dbb1..19c390c83ab5fe 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -11,10 +11,10 @@ from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.components.climate import ( - ATTR_PRESET_MODE, ClimateEntity, ClimateEntityDescription, ClimateEntityFeature, + ClimateEntityStateAttribute, HVACAction, HVACMode, ) @@ -212,9 +212,12 @@ async def async_added_to_hass(self) -> None: prev_state = await self.async_get_last_state() if ( prev_state is not None - and prev_state.attributes.get(ATTR_PRESET_MODE) is not None + and prev_state.attributes.get(ClimateEntityStateAttribute.PRESET_MODE) + is not None ): - mode = HEAT_MODE.parse(prev_state.attributes.get(ATTR_PRESET_MODE)) + mode = HEAT_MODE.parse( + prev_state.attributes.get(ClimateEntityStateAttribute.PRESET_MODE) + ) _LOGGER.debug( "Startup setting last_preset to %s from prev_state", mode.name, diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index c75565c1dd7643..304a90d19ae7d1 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -10,12 +10,11 @@ from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from homeassistant.components.update import ( - ATTR_INSTALLED_VERSION, - ATTR_LATEST_VERSION, UpdateDeviceClass, UpdateEntity, UpdateEntityDescription, UpdateEntityFeature, + UpdateEntityStateAttribute, ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback @@ -391,7 +390,9 @@ def installed_version(self) -> str | None: if self.last_state is None: return None - return self.last_state.attributes.get(ATTR_INSTALLED_VERSION) + return self.last_state.attributes.get( + UpdateEntityStateAttribute.INSTALLED_VERSION + ) @property @override @@ -407,7 +408,7 @@ def latest_version(self) -> str | None: if self.last_state is None: return None - return self.last_state.attributes.get(ATTR_LATEST_VERSION) + return self.last_state.attributes.get(UpdateEntityStateAttribute.LATEST_VERSION) @property @override diff --git a/homeassistant/components/snooz/fan.py b/homeassistant/components/snooz/fan.py index 7ec6eb89b6f508..19db352127968f 100644 --- a/homeassistant/components/snooz/fan.py +++ b/homeassistant/components/snooz/fan.py @@ -14,7 +14,11 @@ ) import voluptuous as vol -from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntity, FanEntityFeature +from homeassistant.components.fan import ( + FanEntity, + FanEntityFeature, + FanEntityStateAttribute, +) from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -109,7 +113,9 @@ async def async_added_to_hass(self) -> None: self._is_on = last_state.state == STATE_ON else: self._is_on = None - self._percentage = last_state.attributes.get(ATTR_PERCENTAGE) + self._percentage = last_state.attributes.get( + FanEntityStateAttribute.PERCENTAGE + ) self.async_on_remove(self._async_subscribe_to_device_change()) diff --git a/homeassistant/components/spaceapi/__init__.py b/homeassistant/components/spaceapi/__init__.py index 106ce1b87192cc..a05867b8fd7f13 100644 --- a/homeassistant/components/spaceapi/__init__.py +++ b/homeassistant/components/spaceapi/__init__.py @@ -15,7 +15,6 @@ ATTR_LOCATION, ATTR_NAME, ATTR_STATE, - ATTR_UNIT_OF_MEASUREMENT, CONF_ADDRESS, CONF_EMAIL, CONF_ENTITY_ID, @@ -23,6 +22,7 @@ CONF_SENSORS, CONF_STATE, CONF_URL, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv @@ -280,8 +280,10 @@ def get_sensor_data( else: sensor_data[ATTR_LOCATION] = spaceapi[CONF_SPACE] # Some sensors don't have a unit of measurement - if ATTR_UNIT_OF_MEASUREMENT in sensor_state.attributes: - sensor_data[ATTR_UNIT] = sensor_state.attributes[ATTR_UNIT_OF_MEASUREMENT] + if EntityStateAttribute.UNIT_OF_MEASUREMENT in sensor_state.attributes: + sensor_data[ATTR_UNIT] = sensor_state.attributes[ + EntityStateAttribute.UNIT_OF_MEASUREMENT + ] return sensor_data diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 688d999a3c64d0..23c595e16d7e76 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -24,14 +24,13 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, CONF_ENTITY_ID, CONF_NAME, CONF_UNIQUE_ID, PERCENTAGE, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import ( CALLBACK_TYPE, @@ -841,7 +840,9 @@ def _calculate_unit_of_measurement(self, new_state: State) -> str | None: state characteristics. """ - base_unit: str | None = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + base_unit: str | None = new_state.attributes.get( + EntityStateAttribute.UNIT_OF_MEASUREMENT + ) unit: str | None = None stat_type = self._state_characteristic if self.is_binary and stat_type in STATS_BINARY_PERCENTAGE: @@ -880,7 +881,7 @@ def _calculate_device_class( if stat_type in STATS_DATETIME: return SensorDeviceClass.TIMESTAMP if stat_type in STATS_NUMERIC_RETAIN_UNIT: - device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) + device_class = new_state.attributes.get(EntityStateAttribute.DEVICE_CLASS) if device_class is None: return None if ( diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index 683f98ec4b2724..7487e64d312f15 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -6,13 +6,12 @@ import switchbot from homeassistant.components.cover import ( - ATTR_CURRENT_POSITION, - ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, ATTR_TILT_POSITION, CoverDeviceClass, CoverEntity, CoverEntityFeature, + CoverEntityStateAttribute, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -77,11 +76,14 @@ async def async_added_to_hass(self) -> None: """Run when entity about to be added.""" await super().async_added_to_hass() last_state = await self.async_get_last_state() - if not last_state or ATTR_CURRENT_POSITION not in last_state.attributes: + if ( + not last_state + or CoverEntityStateAttribute.CURRENT_POSITION not in last_state.attributes + ): return self._attr_current_cover_position = last_state.attributes.get( - ATTR_CURRENT_POSITION + CoverEntityStateAttribute.CURRENT_POSITION ) self._last_run_success = last_state.attributes.get("last_run_success") if self._attr_current_cover_position is not None: @@ -172,11 +174,15 @@ async def async_added_to_hass(self) -> None: """Run when entity about to be added.""" await super().async_added_to_hass() last_state = await self.async_get_last_state() - if not last_state or ATTR_CURRENT_TILT_POSITION not in last_state.attributes: + if ( + not last_state + or CoverEntityStateAttribute.CURRENT_TILT_POSITION + not in last_state.attributes + ): return self._attr_current_cover_tilt_position = last_state.attributes.get( - ATTR_CURRENT_TILT_POSITION + CoverEntityStateAttribute.CURRENT_TILT_POSITION ) self._last_run_success = last_state.attributes.get("last_run_success") if (_tilt := self._attr_current_cover_tilt_position) is not None: @@ -260,11 +266,14 @@ async def async_added_to_hass(self) -> None: """Run when entity about to be added.""" await super().async_added_to_hass() last_state = await self.async_get_last_state() - if not last_state or ATTR_CURRENT_POSITION not in last_state.attributes: + if ( + not last_state + or CoverEntityStateAttribute.CURRENT_POSITION not in last_state.attributes + ): return self._attr_current_cover_position = last_state.attributes.get( - ATTR_CURRENT_POSITION + CoverEntityStateAttribute.CURRENT_POSITION ) self._last_run_success = last_state.attributes.get("last_run_success") if self._attr_current_cover_position is not None: diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py index 0b5bc94ef8b743..971dab2d94eb40 100644 --- a/homeassistant/components/switchbot_cloud/climate.py +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -15,7 +15,6 @@ from homeassistant.components import climate as FanState from homeassistant.components.climate import ( - ATTR_FAN_MODE, ATTR_TEMPERATURE, PRESET_BOOST, PRESET_COMFORT, @@ -24,6 +23,7 @@ PRESET_NONE, ClimateEntity, ClimateEntityFeature, + ClimateEntityStateAttribute, HVACMode, ) from homeassistant.const import ( @@ -128,10 +128,10 @@ async def async_added_to_hass(self) -> None: _LOGGER.debug("Last state attributes: %s", last_state.attributes) self._attr_hvac_mode = HVACMode(last_state.state) self._attr_fan_mode = last_state.attributes.get( - ATTR_FAN_MODE, self._attr_fan_mode + ClimateEntityStateAttribute.FAN_MODE, self._attr_fan_mode ) self._attr_target_temperature = last_state.attributes.get( - ATTR_TEMPERATURE, self._attr_target_temperature + ClimateEntityStateAttribute.TEMPERATURE, self._attr_target_temperature ) def _get_mode(self, hvac_mode: HVACMode | None) -> int: diff --git a/homeassistant/components/switchbot_cloud/vacuum.py b/homeassistant/components/switchbot_cloud/vacuum.py index 892b1f1bd021ed..7c3ff8f5584be6 100644 --- a/homeassistant/components/switchbot_cloud/vacuum.py +++ b/homeassistant/components/switchbot_cloud/vacuum.py @@ -75,8 +75,7 @@ class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity): # "Robot Vacuum Cleaner S1 Plus" _attr_supported_features: VacuumEntityFeature = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.FAN_SPEED + VacuumEntityFeature.FAN_SPEED | VacuumEntityFeature.PAUSE | VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.START @@ -123,7 +122,6 @@ def _set_attributes(self) -> None: if self.coordinator.data is None: return - self._attr_battery_level = self.coordinator.data.get("battery") self._attr_available = self.coordinator.data.get("onlineStatus") == "online" switchbot_state = str(self.coordinator.data.get("workingStatus")) diff --git a/homeassistant/components/temperature/condition.py b/homeassistant/components/temperature/condition.py index 9b407851eb64c5..a793108de64d39 100644 --- a/homeassistant/components/temperature/condition.py +++ b/homeassistant/components/temperature/condition.py @@ -3,20 +3,19 @@ from typing import override from homeassistant.components.climate import ( - ATTR_CURRENT_TEMPERATURE as CLIMATE_ATTR_CURRENT_TEMPERATURE, DOMAIN as CLIMATE_DOMAIN, + ClimateEntityStateAttribute, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.components.water_heater import ( - ATTR_CURRENT_TEMPERATURE as WATER_HEATER_ATTR_CURRENT_TEMPERATURE, DOMAIN as WATER_HEATER_DOMAIN, + WaterHeaterStateAttribute, ) from homeassistant.components.weather import ( - ATTR_WEATHER_TEMPERATURE, - ATTR_WEATHER_TEMPERATURE_UNIT, DOMAIN as WEATHER_DOMAIN, + WeatherEntityStateAttribute, ) -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature +from homeassistant.const import EntityStateAttribute, UnitOfTemperature from homeassistant.core import HomeAssistant, State from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import ( @@ -27,16 +26,16 @@ TEMPERATURE_DOMAIN_SPECS: dict[str, DomainSpec] = { CLIMATE_DOMAIN: DomainSpec( - value_source=CLIMATE_ATTR_CURRENT_TEMPERATURE, + value_source=ClimateEntityStateAttribute.CURRENT_TEMPERATURE, ), SENSOR_DOMAIN: DomainSpec( device_class=SensorDeviceClass.TEMPERATURE, ), WATER_HEATER_DOMAIN: DomainSpec( - value_source=WATER_HEATER_ATTR_CURRENT_TEMPERATURE, + value_source=WaterHeaterStateAttribute.CURRENT_TEMPERATURE, ), WEATHER_DOMAIN: DomainSpec( - value_source=ATTR_WEATHER_TEMPERATURE, + value_source=WeatherEntityStateAttribute.TEMPERATURE, ), } @@ -68,9 +67,11 @@ def _should_include(self, state: State) -> bool: def _get_entity_unit(self, entity_state: State) -> str | None: """Get the temperature unit of an entity from its state.""" if entity_state.domain == SENSOR_DOMAIN: - return entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + return entity_state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) if entity_state.domain == WEATHER_DOMAIN: - return entity_state.attributes.get(ATTR_WEATHER_TEMPERATURE_UNIT) + return entity_state.attributes.get( + WeatherEntityStateAttribute.TEMPERATURE_UNIT + ) # Climate and water_heater: show_temp converts to system unit return self._hass.config.units.temperature_unit diff --git a/homeassistant/components/temperature/trigger.py b/homeassistant/components/temperature/trigger.py index a1022422b20719..dd7d6de4384588 100644 --- a/homeassistant/components/temperature/trigger.py +++ b/homeassistant/components/temperature/trigger.py @@ -3,20 +3,19 @@ from typing import override from homeassistant.components.climate import ( - ATTR_CURRENT_TEMPERATURE as CLIMATE_ATTR_CURRENT_TEMPERATURE, DOMAIN as CLIMATE_DOMAIN, + ClimateEntityStateAttribute, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.components.water_heater import ( - ATTR_CURRENT_TEMPERATURE as WATER_HEATER_ATTR_CURRENT_TEMPERATURE, DOMAIN as WATER_HEATER_DOMAIN, + WaterHeaterStateAttribute, ) from homeassistant.components.weather import ( - ATTR_WEATHER_TEMPERATURE, - ATTR_WEATHER_TEMPERATURE_UNIT, DOMAIN as WEATHER_DOMAIN, + WeatherEntityStateAttribute, ) -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature +from homeassistant.const import EntityStateAttribute, UnitOfTemperature from homeassistant.core import HomeAssistant, State from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( @@ -29,14 +28,16 @@ TEMPERATURE_DOMAIN_SPECS: dict[str, DomainSpec] = { CLIMATE_DOMAIN: DomainSpec( - value_source=CLIMATE_ATTR_CURRENT_TEMPERATURE, + value_source=ClimateEntityStateAttribute.CURRENT_TEMPERATURE, ), SENSOR_DOMAIN: DomainSpec( device_class=SensorDeviceClass.TEMPERATURE, ), - WATER_HEATER_DOMAIN: DomainSpec(value_source=WATER_HEATER_ATTR_CURRENT_TEMPERATURE), + WATER_HEATER_DOMAIN: DomainSpec( + value_source=WaterHeaterStateAttribute.CURRENT_TEMPERATURE + ), WEATHER_DOMAIN: DomainSpec( - value_source=ATTR_WEATHER_TEMPERATURE, + value_source=WeatherEntityStateAttribute.TEMPERATURE, ), } @@ -70,9 +71,9 @@ def _should_include(self, state: State) -> bool: def _get_entity_unit(self, state: State) -> str | None: """Get the temperature unit of an entity from its state.""" if state.domain == SENSOR_DOMAIN: - return state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + return state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) if state.domain == WEATHER_DOMAIN: - return state.attributes.get(ATTR_WEATHER_TEMPERATURE_UNIT) + return state.attributes.get(WeatherEntityStateAttribute.TEMPERATURE_UNIT) # Climate and water_heater: show_temp converts to system unit return self._hass.config.units.temperature_unit diff --git a/homeassistant/components/tesla_fleet/device_tracker.py b/homeassistant/components/tesla_fleet/device_tracker.py index 4b4df475421690..986c342c50a670 100644 --- a/homeassistant/components/tesla_fleet/device_tracker.py +++ b/homeassistant/components/tesla_fleet/device_tracker.py @@ -2,7 +2,10 @@ from typing import override -from homeassistant.components.device_tracker import TrackerEntity +from homeassistant.components.device_tracker import ( + TrackerEntity, + TrackerEntityStateAttribute, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -52,8 +55,12 @@ async def async_added_to_hass(self) -> None: and self._attr_latitude is None and self._attr_longitude is None ): - self._attr_latitude = state.attributes.get("latitude") - self._attr_longitude = state.attributes.get("longitude") + self._attr_latitude = state.attributes.get( + TrackerEntityStateAttribute.LATITUDE + ) + self._attr_longitude = state.attributes.get( + TrackerEntityStateAttribute.LONGITUDE + ) class TeslaFleetDeviceTrackerLocationEntity(TeslaFleetDeviceTrackerEntity): diff --git a/homeassistant/components/tesla_fleet/sensor.py b/homeassistant/components/tesla_fleet/sensor.py index a4d08e49c6b69a..fa8ccaf9c97470 100644 --- a/homeassistant/components/tesla_fleet/sensor.py +++ b/homeassistant/components/tesla_fleet/sensor.py @@ -11,6 +11,7 @@ SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorEntityStateAttribute, SensorStateClass, ) from homeassistant.const import ( @@ -526,7 +527,12 @@ async def async_added_to_hass(self) -> None: if ( self.entity_description.key in CHARGE_ENERGY_RESET_KEYS and (last_state := await self.async_get_last_state()) is not None - and (last_reset := last_state.attributes.get("last_reset")) is not None + and ( + last_reset := last_state.attributes.get( + SensorEntityStateAttribute.LAST_RESET + ) + ) + is not None ): self._attr_last_reset = dt_util.parse_datetime(str(last_reset)) diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index f6bba223b7cff9..3601c2d353ff06 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -289,10 +289,14 @@ def __init__( def _async_update_attrs(self) -> None: """Handle updated data from the coordinator.""" self._climate = bool(self.get("climate_state_is_climate_on")) - if not isinstance(self._value, int): - self._attr_current_option = None + value = self._value + # Defensive clamp: Tesla could report a level outside the modeled + # range, so map it to the nearest known option rather than erroring. + if isinstance(value, int): + options = self.entity_description.options + self._attr_current_option = options[max(0, min(value, len(options) - 1))] else: - self._attr_current_option = self.entity_description.options[self._value] + self._attr_current_option = None class TeslemetryStreamingSelectEntity( @@ -336,10 +340,13 @@ async def async_added_to_hass(self) -> None: def _value_callback(self, value: int | None) -> None: """Update the value of the entity.""" - if value is None: - self._attr_current_option = None + # Defensive clamp: Tesla could report a level outside the modeled + # range, so map it to the nearest known option rather than erroring. + if isinstance(value, int): + options = self.entity_description.options + self._attr_current_option = options[max(0, min(value, len(options) - 1))] else: - self._attr_current_option = self.entity_description.options[value] + self._attr_current_option = None self.async_write_ha_state() def _climate_callback(self, value: bool | None) -> None: diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py index 7297e6cfc10898..d3e552883d7efb 100644 --- a/homeassistant/components/teslemetry/update.py +++ b/homeassistant/components/teslemetry/update.py @@ -135,6 +135,7 @@ class TeslemetryStreamingUpdateEntity( _download_percentage: int = 0 _install_percentage: int = 0 + _scheduled: bool = False def __init__( self, @@ -154,7 +155,7 @@ async def async_added_to_hass(self) -> None: await super().async_added_to_hass() if (state := await self.async_get_last_state()) is not None: self._attr_in_progress = state.attributes.get("in_progress", False) - self._install_percentage = state.attributes.get("install_percentage", False) + self._attr_update_percentage = state.attributes.get("update_percentage") self._attr_installed_version = state.attributes.get("installed_version") self._attr_latest_version = state.attributes.get("latest_version") self._attr_supported_features = UpdateEntityFeature( @@ -162,6 +163,7 @@ async def async_added_to_hass(self) -> None: "supported_features", self._attr_supported_features ) ) + self._scheduled = self._attr_in_progress self.async_write_ha_state() self.async_on_remove( @@ -217,7 +219,8 @@ def _async_handle_software_update_scheduled_start_time( ) -> None: """Handle software update scheduled start time.""" - self._attr_in_progress = value is not None + self._scheduled = value is not None + self._async_update_progress() self.async_write_ha_state() def _async_handle_software_update_version(self, value: str | None) -> None: @@ -238,12 +241,12 @@ def _async_handle_version(self, value: str | None) -> None: def _async_update_progress(self) -> None: """Update the progress of the update.""" - if 1 < self._download_percentage < 100: + if 0 < self._download_percentage < 100: self._attr_in_progress = True self._attr_update_percentage = self._download_percentage - elif self._install_percentage > 10: + elif 10 < self._install_percentage < 100: self._attr_in_progress = True self._attr_update_percentage = self._install_percentage else: - self._attr_in_progress = False + self._attr_in_progress = self._scheduled self._attr_update_percentage = None diff --git a/homeassistant/components/text/__init__.py b/homeassistant/components/text/__init__.py index eebfd29452b898..911f3c6aa391c6 100644 --- a/homeassistant/components/text/__init__.py +++ b/homeassistant/components/text/__init__.py @@ -41,7 +41,13 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -__all__ = ["DOMAIN", "TextEntity", "TextEntityDescription", "TextMode"] +__all__ = [ + "DOMAIN", + "TextEntity", + "TextEntityCapabilityAttribute", + "TextEntityDescription", + "TextMode", +] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/tomorrowio/config_flow.py b/homeassistant/components/tomorrowio/config_flow.py index d03dc77a62f1bb..7067858ad26463 100644 --- a/homeassistant/components/tomorrowio/config_flow.py +++ b/homeassistant/components/tomorrowio/config_flow.py @@ -21,11 +21,11 @@ ) from homeassistant.const import ( CONF_API_KEY, - CONF_FRIENDLY_NAME, CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -141,7 +141,9 @@ async def async_step_user( user_input[CONF_NAME] = DEFAULT_NAME # Append zone name if it exists and we are using the default name if zone_state := async_active_zone(self.hass, latitude, longitude): - zone_name = zone_state.attributes[CONF_FRIENDLY_NAME] + zone_name = zone_state.attributes[ + EntityStateAttribute.FRIENDLY_NAME + ] user_input[CONF_NAME] += f" - {zone_name}" try: await TomorrowioV4( diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 9e5f7af14c0b2a..7a6deddcc4b581 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -56,7 +56,7 @@ def _async_short_mac(mac: str) -> str: return _async_unifi_mac_from_hass(mac)[-6:] -async def _async_resolve(hass: HomeAssistant, host: str) -> str | None: +async def _async_resolve(hass: HomeAssistant, host: str) -> str | int | None: """Resolve a hostname to an ip.""" with contextlib.suppress(OSError): return next( diff --git a/homeassistant/components/venstar/manifest.json b/homeassistant/components/venstar/manifest.json index eba5c8a6cd483b..ddba18fd9daa9b 100644 --- a/homeassistant/components/venstar/manifest.json +++ b/homeassistant/components/venstar/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["venstarcolortouch"], - "requirements": ["venstarcolortouch==0.21"] + "requirements": ["venstarcolortouch==0.22"] } diff --git a/homeassistant/components/yale/lock.py b/homeassistant/components/yale/lock.py index fbdbd69285de71..8ef2f179b5cf77 100644 --- a/homeassistant/components/yale/lock.py +++ b/homeassistant/components/yale/lock.py @@ -8,7 +8,11 @@ from yalexs.lock import Lock, LockOperation, LockStatus from yalexs.util import get_latest_activity, update_lock_detail_from_activity -from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity, LockEntityFeature +from homeassistant.components.lock import ( + LockEntity, + LockEntityFeature, + LockEntityStateAttribute, +) from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -140,5 +144,7 @@ async def async_added_to_hass(self) -> None: if not (last_state := await self.async_get_last_state()): return - if ATTR_CHANGED_BY in last_state.attributes: - self._attr_changed_by = last_state.attributes[ATTR_CHANGED_BY] + if LockEntityStateAttribute.CHANGED_BY in last_state.attributes: + self._attr_changed_by = last_state.attributes[ + LockEntityStateAttribute.CHANGED_BY + ] diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index b0a14960fca7eb..07e6ff6d0c46e8 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import ( +from homeassistant.const import ( # noqa: F401 ATTR_EDITABLE, ATTR_LATITUDE, ATTR_LONGITUDE, @@ -45,7 +45,14 @@ from homeassistant.util.hass_dict import HassKey from homeassistant.util.location import distance -from .const import ATTR_PASSIVE, ATTR_RADIUS, CONF_PASSIVE, DOMAIN, HOME_ZONE +from .const import ( # noqa: F401 + ATTR_PASSIVE, + ATTR_RADIUS, + CONF_PASSIVE, + DOMAIN, + HOME_ZONE, + ZoneEntityStateAttribute, +) _LOGGER = logging.getLogger(__name__) @@ -142,21 +149,24 @@ def async_in_zones( zone_dist := distance( latitude, longitude, - zone_attrs[ATTR_LATITUDE], - zone_attrs[ATTR_LONGITUDE], + zone_attrs[ZoneEntityStateAttribute.LATITUDE], + zone_attrs[ZoneEntityStateAttribute.LONGITUDE], ) ) is None # Skip zone that are outside the radius aka the # lat/long is outside the zone - or not (zone_dist - (zone_radius := zone_attrs[ATTR_RADIUS]) < radius) + or not ( + zone_dist - (zone_radius := zone_attrs[ZoneEntityStateAttribute.RADIUS]) + < radius + ) ): continue zones.append((zone.entity_id, zone_dist, zone_radius)) # Skip passive zones - if zone_attrs.get(ATTR_PASSIVE): + if zone_attrs.get(ZoneEntityStateAttribute.PASSIVE): continue # Prefer the smallest zone, using distance to its center as a tie @@ -198,9 +208,9 @@ def async_get_enclosing_zones(hass: HomeAssistant, zone_entity_id: str) -> list[ ): return [] input_attrs = input_zone.attributes - input_latitude: float = input_attrs[ATTR_LATITUDE] - input_longitude: float = input_attrs[ATTR_LONGITUDE] - input_radius: float = input_attrs[ATTR_RADIUS] + input_latitude: float = input_attrs[ZoneEntityStateAttribute.LATITUDE] + input_longitude: float = input_attrs[ZoneEntityStateAttribute.LONGITUDE] + input_radius: float = input_attrs[ZoneEntityStateAttribute.RADIUS] zones: list[tuple[str, float, float]] = [] @@ -221,12 +231,12 @@ def async_get_enclosing_zones(hass: HomeAssistant, zone_entity_id: str) -> list[ zone_dist := distance( input_latitude, input_longitude, - zone_attrs[ATTR_LATITUDE], - zone_attrs[ATTR_LONGITUDE], + zone_attrs[ZoneEntityStateAttribute.LATITUDE], + zone_attrs[ZoneEntityStateAttribute.LONGITUDE], ) ) is None: continue - zone_radius = zone_attrs[ATTR_RADIUS] + zone_radius = zone_attrs[ZoneEntityStateAttribute.RADIUS] if not zone_dist + input_radius <= zone_radius: continue zones.append((zone.entity_id, zone_dist, zone_radius)) @@ -281,13 +291,15 @@ def in_zone(zone: State, latitude: float, longitude: float, radius: float = 0) - zone_dist = distance( latitude, longitude, - zone.attributes[ATTR_LATITUDE], - zone.attributes[ATTR_LONGITUDE], + zone.attributes[ZoneEntityStateAttribute.LATITUDE], + zone.attributes[ZoneEntityStateAttribute.LONGITUDE], ) - if zone_dist is None or zone.attributes[ATTR_RADIUS] is None: + if zone_dist is None or zone.attributes[ZoneEntityStateAttribute.RADIUS] is None: return False - return zone_dist - radius < cast(float, zone.attributes[ATTR_RADIUS]) + return zone_dist - radius < cast( + float, zone.attributes[ZoneEntityStateAttribute.RADIUS] + ) class ZoneStorageCollection(collection.DictStorageCollection): @@ -508,12 +520,12 @@ async def async_added_to_hass(self) -> None: def _generate_attrs(self) -> None: """Generate new attrs based on config.""" self._attr_extra_state_attributes = { - ATTR_LATITUDE: self._config[CONF_LATITUDE], - ATTR_LONGITUDE: self._config[CONF_LONGITUDE], - ATTR_RADIUS: self._config[CONF_RADIUS], - ATTR_PASSIVE: self._config[CONF_PASSIVE], - ATTR_PERSONS: sorted(self._persons_in_zone), - ATTR_EDITABLE: self.editable, + ZoneEntityStateAttribute.LATITUDE: self._config[CONF_LATITUDE], + ZoneEntityStateAttribute.LONGITUDE: self._config[CONF_LONGITUDE], + ZoneEntityStateAttribute.RADIUS: self._config[CONF_RADIUS], + ZoneEntityStateAttribute.PASSIVE: self._config[CONF_PASSIVE], + ZoneEntityStateAttribute.PERSONS: sorted(self._persons_in_zone), + ZoneEntityStateAttribute.EDITABLE: self.editable, } @callback diff --git a/homeassistant/components/zone/const.py b/homeassistant/components/zone/const.py index af6ac00cbbb5fa..9e9215d6f23a58 100644 --- a/homeassistant/components/zone/const.py +++ b/homeassistant/components/zone/const.py @@ -1,7 +1,22 @@ """Constants for the zone component.""" +from enum import StrEnum + CONF_PASSIVE = "passive" DOMAIN = "zone" HOME_ZONE = "home" + + +class ZoneEntityStateAttribute(StrEnum): + """State attributes for zone entities.""" + + LATITUDE = "latitude" + LONGITUDE = "longitude" + RADIUS = "radius" + PASSIVE = "passive" + PERSONS = "persons" + EDITABLE = "editable" + + ATTR_PASSIVE = "passive" ATTR_RADIUS = "radius" diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index b9e53374269068..131d8e1fcb8458 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -557,7 +557,7 @@ async def async_turn_on(self) -> None: # and cool to mirror previous behavior. If none of those are available, set it # to the first available mode that is not off. try: - hvac_mode = next( + hvac_mode: HVACMode = next( mode for mode in (HVACMode.HEAT_COOL, HVACMode.HEAT, HVACMode.COOL) if mode in self._hvac_modes diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e17d12b77ef1e9..2967780a36cd42 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -435,6 +435,7 @@ "london_underground", "lookin", "loqed", + "luci", "luftdaten", "lunatone", "lupusec", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 0fb73355adb841..318118aaf05a20 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5116,7 +5116,7 @@ "integrations": { "luci": { "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling", "name": "OpenWrt (luci)" }, diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 61936a49c0d303..0150f93fe15b24 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -18,7 +18,7 @@ import time from time import monotonic import traceback -from typing import Any +from typing import Any, override import packaging.tags @@ -173,7 +173,7 @@ class RuntimeConfig: safe_mode: bool = False -class HassEventLoopPolicy(asyncio.DefaultEventLoopPolicy): # type: ignore[name-defined,misc] +class HassEventLoopPolicy(asyncio.DefaultEventLoopPolicy): """Event loop policy for Home Assistant.""" def __init__(self, debug: bool) -> None: @@ -184,8 +184,9 @@ def __init__(self, debug: bool) -> None: @property def loop_name(self) -> str: """Return name of the loop.""" - return self._loop_factory.__name__ # type: ignore[no-any-return] + return self._loop_factory.__name__ # type: ignore[attr-defined,no-any-return] + @override def new_event_loop(self) -> asyncio.AbstractEventLoop: """Get the event loop.""" loop: asyncio.AbstractEventLoop = super().new_event_loop() diff --git a/requirements_all.txt b/requirements_all.txt index 025a527d810283..9ebe7825b0b92d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1597,6 +1597,9 @@ moehlenhoff-alpha2==1.4.0 # homeassistant.components.route_b_smart_meter momonga==0.3.0 +# homeassistant.components.monarch_money +monarchmoneycommunity==1.5.1 + # homeassistant.components.monzo monzopy==1.5.1 @@ -3236,9 +3239,6 @@ twilio==6.32.0 # homeassistant.components.twitch twitchAPI==4.2.1 -# homeassistant.components.monarch_money -typedmonarchmoney==0.7.0 - # homeassistant.components.ukraine_alarm uasiren==0.0.1 @@ -3290,7 +3290,7 @@ vehicle==3.0.0 velbus-aio==2026.4.1 # homeassistant.components.venstar -venstarcolortouch==0.21 +venstarcolortouch==0.22 # homeassistant.components.viaggiatreno viaggiatreno_ha==0.2.4 diff --git a/requirements_test.txt b/requirements_test.txt index 7378f18e9778ed..c5156f0088bd43 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,15 +8,15 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt # ast-serialize is an internal mypy dependency -ast-serialize==0.3.0 +ast-serialize==0.6.0 astroid==4.0.4 coverage==7.14.3 freezegun==1.5.5 # librt is an internal mypy dependency -librt==0.11.0 +librt==0.12.0 license-expression==30.4.3 mock-open==1.4.0 -mypy==2.1.0 +mypy==2.2.0 prek==0.2.28 pydantic==2.13.4 PyGithub==2.9.1 diff --git a/tests/components/assist_satellite/test_llm.py b/tests/components/assist_satellite/test_llm.py new file mode 100644 index 00000000000000..9a65b35b9f8ac0 --- /dev/null +++ b/tests/components/assist_satellite/test_llm.py @@ -0,0 +1,34 @@ +"""Tests for the assist_satellite LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + + +@pytest.fixture(autouse=True) +async def init_integrations(hass: HomeAssistant) -> None: + """Set up the integrations; assist_satellite registers the broadcast intent.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "llm", {}) + assert await async_setup_component(hass, "assist_satellite", {}) + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def test_broadcast_tool_offered(hass: HomeAssistant) -> None: + """Test the broadcast intent is exposed as an LLM tool.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + assert "HassBroadcast" in [tool.name for tool in result.tools] diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 1ccb6de166846c..31facbab450aad 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -899,10 +899,6 @@ def supervisor_client() -> Generator[AsyncMock]: "homeassistant.components.hassio.issues.get_supervisor_client", return_value=supervisor_client, ), - patch( - "homeassistant.components.hassio.jobs.get_supervisor_client", - return_value=supervisor_client, - ), patch( "homeassistant.components.hassio.repairs.get_supervisor_client", return_value=supervisor_client, diff --git a/tests/components/google_health/test_config_flow.py b/tests/components/google_health/test_config_flow.py index daae98d2195389..2ac6eaf3cde7ba 100644 --- a/tests/components/google_health/test_config_flow.py +++ b/tests/components/google_health/test_config_flow.py @@ -2,7 +2,10 @@ from unittest.mock import AsyncMock, patch -from google_health_api.exceptions import GoogleHealthApiError +from google_health_api.exceptions import ( + GoogleHealthApiError, + HealthApiForbiddenException, +) from google_health_api.model import Identity import pytest @@ -175,6 +178,51 @@ async def test_config_flow_get_identity_error( assert result["reason"] == "cannot_connect" +@pytest.mark.usefixtures( + "current_request_with_host", "mock_setup_entry", "setup_credentials" +) +async def test_config_flow_api_not_enabled( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_google_health_client: AsyncMock, +) -> None: + """Test config flow aborts if the Google Health API is not enabled.""" + mock_google_health_client.get_identity.side_effect = HealthApiForbiddenException + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": " ".join(OAUTH_SCOPES), + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "api_not_enabled" + assert result["description_placeholders"] == { + "url": "https://console.developers.google.com/apis/api/health.googleapis.com/overview" + } + + @pytest.mark.usefixtures( "current_request_with_host", "mock_setup_entry", "setup_credentials" ) diff --git a/tests/components/hassio/test_jobs.py b/tests/components/hassio/test_jobs.py index fca6691020d0a7..11bbd227da5ef1 100644 --- a/tests/components/hassio/test_jobs.py +++ b/tests/components/hassio/test_jobs.py @@ -9,14 +9,25 @@ from aiohasupervisor.models import Job, JobsInfo import pytest -from homeassistant.components.hassio.const import DOMAIN, MAIN_COORDINATOR -from homeassistant.components.hassio.coordinator import HassioMainDataUpdateCoordinator -from homeassistant.components.hassio.jobs import JobSubscription +from homeassistant.components.hassio.const import ( + DOMAIN, + JOBS_COORDINATOR, + MAIN_COORDINATOR, + REQUEST_REFRESH_DELAY, + SUPERVISOR_JOBS_UPDATE_INTERVAL, +) +from homeassistant.components.hassio.coordinator import ( + HassioMainDataUpdateCoordinator, + JobSubscription, + SupervisorJobsCoordinator, +) from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from .test_init import MOCK_ENVIRON +from tests.common import async_fire_time_changed from tests.typing import WebSocketGenerator @@ -65,10 +76,10 @@ async def test_job_manager_setup(hass: HomeAssistant, jobs_info: AsyncMock) -> N assert result jobs_info.assert_called_once() - data_coordinator: HassioMainDataUpdateCoordinator = hass.data[MAIN_COORDINATOR] - assert len(data_coordinator.jobs.current_jobs) == 2 - assert data_coordinator.jobs.current_jobs[0].name == "test_job" - assert data_coordinator.jobs.current_jobs[1].name == "test_inner_job" + jobs_coordinator: SupervisorJobsCoordinator = hass.data[JOBS_COORDINATOR] + assert len(jobs_coordinator.current_jobs) == 2 + assert jobs_coordinator.current_jobs[0].name == "test_job" + assert jobs_coordinator.current_jobs[1].name == "test_inner_job" @pytest.mark.usefixtures("all_setup_requests") @@ -100,8 +111,8 @@ async def test_job_manager_ws_updates( jobs_info.reset_mock() client = await hass_supervisor_ws_client() - data_coordinator: HassioMainDataUpdateCoordinator = hass.data[MAIN_COORDINATOR] - assert not data_coordinator.jobs.current_jobs + jobs_coordinator: SupervisorJobsCoordinator = hass.data[JOBS_COORDINATOR] + assert not jobs_coordinator.current_jobs # Make an example listener job_data: Job | None = None @@ -114,7 +125,7 @@ def mock_subscription_callback(job: Job) -> None: subscription = JobSubscription( mock_subscription_callback, name="test_job", reference="test" ) - unsubscribe = data_coordinator.jobs.subscribe(subscription) + unsubscribe = jobs_coordinator.subscribe(subscription) # Send start of job update await client.send_json( @@ -146,7 +157,7 @@ def mock_subscription_callback(job: Job) -> None: assert job_data.progress == 0 assert job_data.done is False # One job in the cache - assert len(data_coordinator.jobs.current_jobs) == 1 + assert len(jobs_coordinator.current_jobs) == 1 # Example progress update await client.send_json( @@ -178,7 +189,7 @@ def mock_subscription_callback(job: Job) -> None: assert job_data.progress == 50 assert job_data.done is False # Same job, same number of jobs in cache - assert len(data_coordinator.jobs.current_jobs) == 1 + assert len(jobs_coordinator.current_jobs) == 1 # Unrelated job update - name change, subscriber should not receive await client.send_json( @@ -208,7 +219,7 @@ def mock_subscription_callback(job: Job) -> None: assert job_data.name == "test_job" assert job_data.reference == "test" # New job, cache increases - assert len(data_coordinator.jobs.current_jobs) == 2 + assert len(jobs_coordinator.current_jobs) == 2 # Unrelated job update - reference change, subscriber should not receive await client.send_json( @@ -238,7 +249,7 @@ def mock_subscription_callback(job: Job) -> None: assert job_data.name == "test_job" assert job_data.reference == "test" # New job, cache increases - assert len(data_coordinator.jobs.current_jobs) == 3 + assert len(jobs_coordinator.current_jobs) == 3 # Unsubscribe mock listener, should not receive final update unsubscribe() @@ -271,7 +282,7 @@ def mock_subscription_callback(job: Job) -> None: assert job_data.progress == 50 assert job_data.done is False # Job ended, cache decreases - assert len(data_coordinator.jobs.current_jobs) == 2 + assert len(jobs_coordinator.current_jobs) == 2 # REST API should not be used during this sequence jobs_info.assert_not_called() @@ -306,9 +317,9 @@ async def test_job_manager_reload_on_supervisor_restart( assert result jobs_info.assert_called_once() - data_coordinator: HassioMainDataUpdateCoordinator = hass.data[MAIN_COORDINATOR] - assert len(data_coordinator.jobs.current_jobs) == 1 - assert data_coordinator.jobs.current_jobs[0].name == "test_job" + jobs_coordinator: SupervisorJobsCoordinator = hass.data[JOBS_COORDINATOR] + assert len(jobs_coordinator.current_jobs) == 1 + assert jobs_coordinator.current_jobs[0].name == "test_job" jobs_info.reset_mock() jobs_info.return_value = JobsInfo(ignore_conditions=[], jobs=[]) @@ -323,7 +334,7 @@ def mock_subscription_callback(job: Job) -> None: job_data = job subscription = JobSubscription(mock_subscription_callback, name="test_job") - data_coordinator.jobs.subscribe(subscription) + jobs_coordinator.subscribe(subscription) # Send supervisor restart signal await client.send_json( @@ -341,12 +352,18 @@ def mock_subscription_callback(job: Job) -> None: assert msg["success"] await hass.async_block_till_done() + # Advance time past the debouncer cooldown for the refresh to complete + async_fire_time_changed( + hass, dt_util.utcnow() + dt_util.dt.timedelta(seconds=REQUEST_REFRESH_DELAY + 1) + ) + await hass.async_block_till_done() + # Listener should be told job is done and cache cleared out jobs_info.assert_called_once() assert job_data.name == "test_job" assert job_data.reference == "test" assert job_data.done is True - assert not data_coordinator.jobs.current_jobs + assert not jobs_coordinator.current_jobs @pytest.mark.usefixtures("all_setup_requests") @@ -378,7 +395,7 @@ async def test_subscribe_returns_unsubscribe_when_job_already_matches( assert result client = await hass_supervisor_ws_client() - data_coordinator: HassioMainDataUpdateCoordinator = hass.data[MAIN_COORDINATOR] + jobs_coordinator: SupervisorJobsCoordinator = hass.data[JOBS_COORDINATOR] received: list[Job] = [] @@ -387,7 +404,7 @@ def mock_subscription_callback(job: Job) -> None: received.append(job) subscription = JobSubscription(mock_subscription_callback, name="test_job") - unsubscribe = data_coordinator.jobs.subscribe(subscription) + unsubscribe = jobs_coordinator.subscribe(subscription) # Existing matching job is delivered immediately, and a callable unsubscribe # is returned (not the None result of the callback) @@ -422,3 +439,55 @@ def mock_subscription_callback(job: Job) -> None: await hass.async_block_till_done() assert len(received) == 1 + + +@pytest.mark.usefixtures("all_setup_requests") +async def test_job_manager_periodic_refresh( + hass: HomeAssistant, jobs_info: AsyncMock +) -> None: + """Test job manager performs periodic refresh as backstop for dropped WS events.""" + jobs_info.return_value = JobsInfo( + ignore_conditions=[], + jobs=[ + Job( + name="test_job", + reference="test", + uuid=uuid4(), + progress=0, + stage=None, + done=False, + errors=[], + created=datetime.now(), # pylint: disable=home-assistant-enforce-naive-now + extra=None, + child_jobs=[], + ) + ], + ) + + result = await async_setup_component(hass, DOMAIN, {}) + assert result + jobs_info.assert_called_once() + + jobs_coordinator: SupervisorJobsCoordinator = hass.data[JOBS_COORDINATOR] + assert len(jobs_coordinator.current_jobs) == 1 + + # Subscribe to job updates + job_data: Job | None = None + + @callback + def mock_subscription_callback(job: Job) -> None: + nonlocal job_data + job_data = job + + subscription = JobSubscription(mock_subscription_callback, name="test_job") + jobs_coordinator.subscribe(subscription) + + # Reset mock to verify periodic refresh + jobs_info.reset_mock() + + # Advance time past the SUPERVISOR_JOBS_UPDATE_INTERVAL to trigger periodic refresh + async_fire_time_changed(hass, dt_util.utcnow() + SUPERVISOR_JOBS_UPDATE_INTERVAL) + await hass.async_block_till_done() + + # Periodic refresh should have called jobs_info + jobs_info.assert_called() diff --git a/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr b/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr index d4e2620bbe64a1..6f11fea1a50b48 100644 --- a/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr +++ b/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr @@ -1,25 +1,6 @@ # serializer version: 1 # name: test_states set({ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'editable': True, - : 'test home', - : 'mdi:home', - 'latitude': 32.87336, - 'longitude': -117.22743, - 'passive': False, - 'persons': list([ - ]), - 'radius': 100, - }), - 'context': , - 'entity_id': 'zone.home', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }), StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Demo scanner', @@ -55,5 +36,24 @@ 'last_updated': , 'state': 'home', }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : True, + : 'test home', + : 'mdi:home', + : 32.87336, + : -117.22743, + : False, + : list([ + ]), + : 100, + }), + 'context': , + 'entity_id': 'zone.home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }), }) # --- diff --git a/tests/components/luci/__init__.py b/tests/components/luci/__init__.py new file mode 100644 index 00000000000000..c1710d62777fc5 --- /dev/null +++ b/tests/components/luci/__init__.py @@ -0,0 +1 @@ +"""Tests for the luci integration.""" diff --git a/tests/components/luci/conftest.py b/tests/components/luci/conftest.py new file mode 100644 index 00000000000000..0e86c007034552 --- /dev/null +++ b/tests/components/luci/conftest.py @@ -0,0 +1,93 @@ +"""Fixtures for the luci integration tests.""" + +from collections.abc import Generator +from typing import NamedTuple +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.luci.const import DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) + +from tests.common import MockConfigEntry + + +class MockDevice(NamedTuple): + """Mock device from OpenWrt.""" + + mac: str + hostname: str + ip: str + reachable: bool + host: str + + +MOCK_DEVICE_1 = MockDevice( + mac="AA:BB:CC:DD:EE:FF", + hostname="device1", + ip="192.168.1.100", + reachable=True, + host="192.168.1.1", +) +MOCK_DEVICE_2 = MockDevice( + mac="11:22:33:44:55:66", + hostname="device2", + ip="192.168.1.101", + reachable=True, + host="192.168.1.1", +) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.luci.async_setup_entry", + return_value=True, + ) as mock: + yield mock + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + entry_id="01JBVVVJ87F6G5V0QJX6HBC94T", + data={ + CONF_HOST: "192.168.1.1", + CONF_USERNAME: "root", + CONF_PASSWORD: "password", + CONF_SSL: False, + CONF_VERIFY_SSL: True, + }, + ) + + +@pytest.fixture +def mock_luci_client() -> Generator[MagicMock]: + """Return a mock OpenWrtRpc client.""" + with ( + patch( + "homeassistant.components.luci.coordinator.OpenWrtRpc", + autospec=True, + ) as mock_client_class, + patch( + "homeassistant.components.luci.config_flow.OpenWrtRpc", + new=mock_client_class, + ), + patch( + "homeassistant.components.luci.OpenWrtRpc", + new=mock_client_class, + ), + ): + client = mock_client_class.return_value + client.is_logged_in.return_value = True + client.get_all_connected_devices.return_value = [MOCK_DEVICE_1, MOCK_DEVICE_2] + yield client diff --git a/tests/components/luci/snapshots/test_device_tracker.ambr b/tests/components/luci/snapshots/test_device_tracker.ambr new file mode 100644 index 00000000000000..6730cdea729029 --- /dev/null +++ b/tests/components/luci/snapshots/test_device_tracker.ambr @@ -0,0 +1,121 @@ +# serializer version: 1 +# name: test_device_tracker_setup[device_tracker.device1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.device1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'device1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'device1', + 'platform': 'luci', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JBVVVJ87F6G5V0QJX6HBC94T_AA:BB:CC:DD:EE:FF', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker_setup[device_tracker.device1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'device1', + : 'device1', + : list([ + 'zone.home', + ]), + : '192.168.1.100', + : 'AA:BB:CC:DD:EE:FF', + : , + : , + }), + 'context': , + 'entity_id': 'device_tracker.device1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'home', + }) +# --- +# name: test_device_tracker_setup[device_tracker.device2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.device2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'device2', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'device2', + 'platform': 'luci', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JBVVVJ87F6G5V0QJX6HBC94T_11:22:33:44:55:66', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker_setup[device_tracker.device2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'device2', + : 'device2', + : list([ + 'zone.home', + ]), + : '192.168.1.101', + : '11:22:33:44:55:66', + : , + : , + }), + 'context': , + 'entity_id': 'device_tracker.device2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'home', + }) +# --- diff --git a/tests/components/luci/test_config_flow.py b/tests/components/luci/test_config_flow.py new file mode 100644 index 00000000000000..d21be84c49ffdc --- /dev/null +++ b/tests/components/luci/test_config_flow.py @@ -0,0 +1,203 @@ +"""Tests for the luci config flow.""" + +from typing import Any +from unittest.mock import MagicMock + +import pytest +from requests.exceptions import ConnectionError as RequestsConnectionError + +from homeassistant.components.luci.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +REAUTH_INPUT = { + CONF_USERNAME: "root", + CONF_PASSWORD: "new-password", +} + +USER_INPUT = { + CONF_HOST: "192.168.1.1", + CONF_USERNAME: "root", + CONF_PASSWORD: "password", + CONF_SSL: False, + CONF_VERIFY_SSL: True, +} + +# Client configurations that make ``_try_connect`` fail, mapped to the error +# the flow should report. ``cannot_connect`` is a connection error raised by +# the client, ``invalid_auth`` is the client reporting it is not logged in. +CONNECT_ERRORS = [ + ({"side_effect": RequestsConnectionError}, "cannot_connect"), + ({"return_value": False}, "invalid_auth"), +] + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow(hass: HomeAssistant, mock_luci_client: MagicMock) -> None: + """Test the happy path of the user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=USER_INPUT + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "192.168.1.1" + assert result["data"] == USER_INPUT + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_luci_client") +async def test_user_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the user flow aborts when the host is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=USER_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize(("client_config", "base_error"), CONNECT_ERRORS) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_errors( + hass: HomeAssistant, + mock_luci_client: MagicMock, + client_config: dict[str, Any], + base_error: str, +) -> None: + """Test the user flow shows errors and recovers on retry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + mock_luci_client.is_logged_in.configure_mock(**client_config) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=USER_INPUT + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": base_error} + + mock_luci_client.is_logged_in.configure_mock(side_effect=None, return_value=True) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=USER_INPUT + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == USER_INPUT + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_import_flow(hass: HomeAssistant, mock_luci_client: MagicMock) -> None: + """Test the happy path of the import flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=USER_INPUT + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "192.168.1.1" + assert result["data"] == USER_INPUT + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_luci_client") +async def test_import_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the import flow aborts when the host is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=USER_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize(("client_config", "reason"), CONNECT_ERRORS) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_import_flow_errors( + hass: HomeAssistant, + mock_luci_client: MagicMock, + client_config: dict[str, Any], + reason: str, +) -> None: + """Test the import flow aborts when connecting fails.""" + mock_luci_client.is_logged_in.configure_mock(**client_config) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=USER_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_luci_client: MagicMock, +) -> None: + """Test the happy path of the reauth flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=REAUTH_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" + + +@pytest.mark.parametrize(("client_config", "base_error"), CONNECT_ERRORS) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_luci_client: MagicMock, + client_config: dict[str, Any], + base_error: str, +) -> None: + """Test the reauth flow shows errors and recovers on retry.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + + mock_luci_client.is_logged_in.configure_mock(**client_config) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=REAUTH_INPUT + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": base_error} + + mock_luci_client.is_logged_in.configure_mock(side_effect=None, return_value=True) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=REAUTH_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" diff --git a/tests/components/luci/test_device_tracker.py b/tests/components/luci/test_device_tracker.py new file mode 100644 index 00000000000000..7340f8acefc106 --- /dev/null +++ b/tests/components/luci/test_device_tracker.py @@ -0,0 +1,60 @@ +"""Tests for the luci device tracker.""" + +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.const import STATE_HOME, STATE_NOT_HOME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import MOCK_DEVICE_2 + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_luci_client") +async def test_device_tracker_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device tracker entities are created.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_device_tracker_disconnect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_luci_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test device goes not_home when disconnected.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.device1") + assert state is not None + assert state.state == STATE_HOME + + # Simulate device disconnecting + mock_luci_client.get_all_connected_devices.return_value = [MOCK_DEVICE_2] + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.device1") + assert state is not None + assert state.state == STATE_NOT_HOME diff --git a/tests/components/luci/test_init.py b/tests/components/luci/test_init.py new file mode 100644 index 00000000000000..240126c9827b53 --- /dev/null +++ b/tests/components/luci/test_init.py @@ -0,0 +1,110 @@ +"""Tests for the luci integration.""" + +from unittest.mock import MagicMock + +import pytest +from requests.exceptions import ConnectionError as RequestsConnectionError + +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.luci.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PLATFORM, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +YAML_CONFIG = { + DEVICE_TRACKER_DOMAIN: { + CONF_PLATFORM: DOMAIN, + CONF_HOST: "192.168.1.1", + CONF_USERNAME: "root", + CONF_PASSWORD: "password", + } +} + + +@pytest.mark.usefixtures("mock_luci_client") +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unloading a config entry.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + +async def test_setup_entry_cannot_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_luci_client: MagicMock, +) -> None: + """Test setup fails with ConfigEntryNotReady on connection error.""" + mock_luci_client.is_logged_in.side_effect = RequestsConnectionError + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_invalid_auth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_luci_client: MagicMock, +) -> None: + """Test setup fails with ConfigEntryAuthFailed and starts a reauth flow.""" + mock_luci_client.is_logged_in.return_value = False + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + assert flows[0]["context"]["entry_id"] == mock_config_entry.entry_id + + +@pytest.mark.usefixtures("mock_device_tracker_conf") +async def test_yaml_import_invalid_auth( + hass: HomeAssistant, + mock_luci_client: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test importing YAML config creates an issue on invalid auth.""" + mock_luci_client.is_logged_in.return_value = False + + assert await async_setup_component(hass, DEVICE_TRACKER_DOMAIN, YAML_CONFIG) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, "yaml_import_invalid_auth") + assert issue is not None + assert issue.severity == ir.IssueSeverity.ERROR + assert issue.translation_placeholders == {"host": "192.168.1.1"} + + +@pytest.mark.usefixtures("mock_device_tracker_conf") +async def test_yaml_import_cannot_connect( + hass: HomeAssistant, + mock_luci_client: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test importing YAML config creates an issue on connection failure.""" + mock_luci_client.is_logged_in.side_effect = RequestsConnectionError + + assert await async_setup_component(hass, DEVICE_TRACKER_DOMAIN, YAML_CONFIG) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, "yaml_import_cannot_connect") + assert issue is not None + assert issue.severity == ir.IssueSeverity.ERROR + assert issue.translation_placeholders == {"host": "192.168.1.1"} diff --git a/tests/components/melcloud_home/conftest.py b/tests/components/melcloud_home/conftest.py index 274319f87ad146..1eb04056809e3a 100644 --- a/tests/components/melcloud_home/conftest.py +++ b/tests/components/melcloud_home/conftest.py @@ -4,12 +4,17 @@ from unittest.mock import AsyncMock, patch from aiomelcloudhome import MELCloudHome, UserContext +from aiomelcloudhome.models.telemetry import TelemetryValue import pytest from homeassistant.components.melcloud_home.const import DOMAIN from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from tests.common import MockConfigEntry, load_json_value_fixture +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) MOCK_USER_INPUT = { CONF_EMAIL: "user@example.com", @@ -36,8 +41,13 @@ def mock_melcloud_client() -> Generator[AsyncMock]: """Mock MELCloud Home client.""" client = AsyncMock(MELCloudHome) client.get_context.return_value = UserContext.model_validate( - load_json_value_fixture("context.json", DOMAIN) + load_json_object_fixture("context.json", DOMAIN) ) + client.get_energy_telemetry.return_value = [ + TelemetryValue.model_validate(value) + for value in load_json_array_fixture("energy.json", DOMAIN) + ] + with ( patch( "homeassistant.components.melcloud_home.MELCloudHome", diff --git a/tests/components/melcloud_home/fixtures/energy.json b/tests/components/melcloud_home/fixtures/energy.json new file mode 100644 index 00000000000000..b0099eaed4bdf2 --- /dev/null +++ b/tests/components/melcloud_home/fixtures/energy.json @@ -0,0 +1,5 @@ +[ + { "time": "2026-01-14 02:00:00.000000000", "value": "100.0" }, + { "time": "2026-01-14 03:00:00.000000000", "value": "150.5" }, + { "time": "2026-01-14 04:00:00.000000000", "value": "200.0" } +] diff --git a/tests/components/melcloud_home/snapshots/test_sensor.ambr b/tests/components/melcloud_home/snapshots/test_sensor.ambr index 85a3c4d31e276e..44243d8f274d97 100644 --- a/tests/components/melcloud_home/snapshots/test_sensor.ambr +++ b/tests/components/melcloud_home/snapshots/test_sensor.ambr @@ -1,4 +1,66 @@ # serializer version: 1 +# name: test_all_entities[sensor.heat_pump_energy_consumed_monthly-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heat_pump_energy_consumed_monthly', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy consumed (monthly)', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy consumed (monthly)', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_consumed', + 'unique_id': 'atw-unit-uuid-1_energy_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.heat_pump_energy_consumed_monthly-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat Pump Energy consumed (monthly)', + 'last_reset': '2026-06-01T00:00:00+00:00', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_pump_energy_consumed_monthly', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.4505', + }) +# --- # name: test_all_entities[sensor.heat_pump_signal_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/monarch_money/snapshots/test_sensor.ambr b/tests/components/monarch_money/snapshots/test_sensor.ambr index a73e71086ddf73..19322633e2c196 100644 --- a/tests/components/monarch_money/snapshots/test_sensor.ambr +++ b/tests/components/monarch_money/snapshots/test_sensor.ambr @@ -1020,7 +1020,7 @@ # name: test_all_entities[sensor.vinaudit_2050_toyota_rav8_data_age-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Data provided by Monarch Money API via Manual entry', + : 'Data provided by Monarch Money API via vin_audit', : 'timestamp', : 'VinAudit 2050 Toyota RAV8 Data age', }), @@ -1074,7 +1074,7 @@ # name: test_all_entities[sensor.vinaudit_2050_toyota_rav8_value-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Data provided by Monarch Money API via Manual entry', + : 'Data provided by Monarch Money API via vin_audit', : 'monetary', : 'https://api.monarchmoney.com/cdn-cgi/image/width=128/images/institution/159427559853802644', : 'VinAudit 2050 Toyota RAV8 Value', @@ -1131,7 +1131,7 @@ # name: test_all_entities[sensor.zillow_house_balance-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Data provided by Monarch Money API via Manual entry', + : 'Data provided by Monarch Money API via zillow', : 'monetary', : 'data:image/png;base64,base64Nonce', : 'Zillow House Balance', @@ -1186,7 +1186,7 @@ # name: test_all_entities[sensor.zillow_house_data_age-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Data provided by Monarch Money API via Manual entry', + : 'Data provided by Monarch Money API via zillow', : 'timestamp', : 'Zillow House Data age', }), diff --git a/tests/components/monarch_money/test_config_flow.py b/tests/components/monarch_money/test_config_flow.py index d7a23e2e385c20..b33b5f0ff676ed 100644 --- a/tests/components/monarch_money/test_config_flow.py +++ b/tests/components/monarch_money/test_config_flow.py @@ -134,7 +134,9 @@ async def test_form_mfa( assert result["step_id"] == "user" # Add a bad MFA Code response - mock_config_api.return_value.multi_factor_authenticate.side_effect = KeyError + mock_config_api.return_value.multi_factor_authenticate.side_effect = ( + LoginFailedException("Bad MFA code") + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], { diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index 1dc0858909cf88..8b8b605591480c 100644 --- a/tests/components/netatmo/common.py +++ b/tests/components/netatmo/common.py @@ -121,7 +121,7 @@ async def simulate_webhook(hass: HomeAssistant, webhook_id: str, response) -> No def selected_platforms(platforms: list[Platform]) -> Iterator[None]: """Restrict loaded platforms to list given.""" with ( - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", platforms), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", platforms), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), diff --git a/tests/components/netatmo/test_binary_sensor.py b/tests/components/netatmo/test_binary_sensor.py index 8e9988c309d280..65b8b53e680bbc 100644 --- a/tests/components/netatmo/test_binary_sensor.py +++ b/tests/components/netatmo/test_binary_sensor.py @@ -59,7 +59,7 @@ async def fake_post(*args: Any, **kwargs: Any): "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch( - "homeassistant.components.netatmo.data_handler.PLATFORMS", + "homeassistant.components.netatmo.coordinator.PLATFORMS", ["camera", "binary_sensor"], ), patch( @@ -171,7 +171,7 @@ async def fake_tag_post(*args, **kwargs): "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch( - "homeassistant.components.netatmo.data_handler.PLATFORMS", + "homeassistant.components.netatmo.coordinator.PLATFORMS", ["camera", "binary_sensor"], ), patch( @@ -298,7 +298,7 @@ async def fake_tag_post(*args, **kwargs): "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch( - "homeassistant.components.netatmo.data_handler.PLATFORMS", + "homeassistant.components.netatmo.coordinator.PLATFORMS", ["camera", "binary_sensor"], ), patch( diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 7ccbe041f66349..1bc1542f23dbfa 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -474,7 +474,7 @@ async def fake_post(*args: Any, **kwargs: Any): patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["camera"]), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["camera"]), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), @@ -587,7 +587,7 @@ async def fake_post(*args: Any, **kwargs: Any): patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["camera"]), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["camera"]), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), @@ -707,7 +707,7 @@ async def fake_post_no_data(*args, **kwargs): patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["camera"]), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["camera"]), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), @@ -750,7 +750,7 @@ async def fake_post(*args: Any, **kwargs: Any): patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["camera"]), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["camera"]), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index c6f45b12928743..d97ac9fd641c7f 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -129,7 +129,7 @@ async def fake_post(*args, **kwargs): patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth", ) as mock_auth, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["sensor"]), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["sensor"]), ): mock_auth.return_value.async_post_api_request.side_effect = fake_post mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() @@ -301,7 +301,7 @@ async def test_setup_with_cloud( patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", []), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", []), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), @@ -371,7 +371,7 @@ async def test_setup_with_cloudhook(hass: HomeAssistant) -> None: patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", []), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", []), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), @@ -427,7 +427,7 @@ async def test_setup_component_with_delay( "pyatmo.AbstractAsyncAuth.async_post_api_request", side_effect=partial(fake_post_request, hass), ) as mock_post_api_request, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["light"]), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["light"]), ): assert await async_setup_component( hass, DOMAIN, {"netatmo": {"client_id": "123", "client_secret": "abc"}} diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index 9641519d4c046e..4d3d339e4fe72c 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -127,7 +127,7 @@ async def fake_post_request_no_data(*args, **kwargs): patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["light"]), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["light"]), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py index 10c6c2f06d1b95..6877a9ebc14fad 100644 --- a/tests/components/switchbot_cloud/test_init.py +++ b/tests/components/switchbot_cloud/test_init.py @@ -300,7 +300,6 @@ async def test_polling_is_only_disabled_after_webhook_delivery( entity_id = "vacuum.vacuum_name_1" state = hass.states.get(entity_id) assert state is not None - assert state.attributes["battery_level"] == 71 # Change API return values and wait for update mock_get_status.return_value = { @@ -317,7 +316,6 @@ async def test_polling_is_only_disabled_after_webhook_delivery( # Validate that the state was updated again via fetch state = hass.states.get(entity_id) assert state is not None - assert state.attributes["battery_level"] == 60 hass.bus.async_fire(EVENT_HOMEASSISTANT_START) webhook_id = entry.data[CONF_WEBHOOK_ID] diff --git a/tests/components/switchbot_cloud/test_vacuum.py b/tests/components/switchbot_cloud/test_vacuum.py index 71b237aafa2abe..3410be3c7bb449 100644 --- a/tests/components/switchbot_cloud/test_vacuum.py +++ b/tests/components/switchbot_cloud/test_vacuum.py @@ -293,7 +293,6 @@ async def test_k10_plus_webhook_updates_state_after_reload( state = hass.states.get(entity_id) assert state is not None assert state.state == VacuumActivity.CLEANING.value - assert state.attributes["battery_level"] == 74 async def test_k20_plus_pro_set_fan_speed( diff --git a/tests/components/teslemetry/snapshots/test_update.ambr b/tests/components/teslemetry/snapshots/test_update.ambr index 096deb8341f62c..8b0dc76c820b34 100644 --- a/tests/components/teslemetry/snapshots/test_update.ambr +++ b/tests/components/teslemetry/snapshots/test_update.ambr @@ -130,7 +130,7 @@ : 0, : '/api/brands/integration/teslemetry/icon.png', : 'Test Update', - : False, + : True, : '2025.1.1', : '2025.2.1', : None, @@ -138,7 +138,7 @@ : None, : , : None, - : None, + : 50, }), 'context': , 'entity_id': 'update.test_update', @@ -148,7 +148,7 @@ 'state': 'on', }) # --- -# name: test_update_streaming[installing] +# name: test_update_streaming[install_complete] StateSnapshot({ 'attributes': ReadOnlyDict({ : False, @@ -173,6 +173,31 @@ 'state': 'on', }) # --- +# name: test_update_streaming[installing] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 0, + : '/api/brands/integration/teslemetry/icon.png', + : 'Test Update', + : True, + : '2025.1.1', + : '2025.2.1', + : None, + : None, + : None, + : , + : None, + : 50, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_update_streaming[ready] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -248,3 +273,53 @@ 'state': 'off', }) # --- +# name: test_update_streaming_scheduled_not_clobbered[downloading_after_schedule_cleared] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 0, + : '/api/brands/integration/teslemetry/icon.png', + : 'Test Update', + : True, + : None, + : None, + : None, + : None, + : None, + : , + : None, + : 5, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_update_streaming_scheduled_not_clobbered[scheduled] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 0, + : '/api/brands/integration/teslemetry/icon.png', + : 'Test Update', + : True, + : None, + : None, + : None, + : None, + : None, + : , + : None, + : None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/teslemetry/test_select.py b/tests/components/teslemetry/test_select.py index 7c2ec7a34f6de3..b9b893652206ac 100644 --- a/tests/components/teslemetry/test_select.py +++ b/tests/components/teslemetry/test_select.py @@ -1,5 +1,6 @@ """Test the Teslemetry select platform.""" +from collections.abc import Awaitable, Callable from copy import deepcopy from unittest.mock import AsyncMock, patch @@ -15,15 +16,25 @@ DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.components.teslemetry.coordinator import ENERGY_INFO_INTERVAL -from homeassistant.components.teslemetry.select import LEVEL, LOW, MEDIUM, OFF +from homeassistant.components.teslemetry.coordinator import ( + ENERGY_INFO_INTERVAL, + VEHICLE_INTERVAL, +) +from homeassistant.components.teslemetry.select import HIGH, LEVEL, LOW, MEDIUM, OFF from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import assert_entities, reload_platform, setup_platform -from .const import COMMAND_ERRORS, COMMAND_OK, METADATA, SITE_INFO, VEHICLE_DATA_ALT +from .const import ( + COMMAND_ERRORS, + COMMAND_OK, + METADATA, + METADATA_LEGACY, + SITE_INFO, + VEHICLE_DATA_ALT, +) from tests.common import async_fire_time_changed @@ -348,6 +359,79 @@ async def test_select_streaming( assert hass.states.get("select.test_steering_wheel_heater").state == "off" +async def _drive_polling( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, + value: int, +) -> None: + """Push a steering wheel level through the polling path.""" + data = deepcopy(VEHICLE_DATA_ALT) + data["response"]["climate_state"]["steering_wheel_heat_level"] = value + mock_vehicle_data.return_value = data + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + + +async def _drive_streaming( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, + value: int, +) -> None: + """Push a steering wheel level through the streaming path.""" + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": {Signal.HVAC_STEERING_WHEEL_HEAT_LEVEL: value}, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + + +@pytest.mark.parametrize( + ("metadata", "driver"), + [ + pytest.param(METADATA_LEGACY, _drive_polling, id="polling"), + pytest.param(METADATA, _drive_streaming, id="streaming"), + ], +) +@pytest.mark.parametrize( + ("value", "expected"), + [ + pytest.param(2, HIGH, id="level_2_clamped"), + pytest.param(3, HIGH, id="level_3_clamped"), + ], +) +async def test_steering_wheel_heat_levels( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vehicle_data: AsyncMock, + mock_metadata: AsyncMock, + mock_add_listener: AsyncMock, + metadata: dict, + driver: Callable[ + [HomeAssistant, FrozenDateTimeFactory, AsyncMock, AsyncMock, int], + Awaitable[None], + ], + value: int, + expected: str, +) -> None: + """A level beyond the last modeled option clamps to that option, high.""" + freezer.move_to("2024-01-01 00:00:00+00:00") + mock_metadata.return_value = metadata + + await setup_platform(hass, [Platform.SELECT]) + + await driver(hass, freezer, mock_vehicle_data, mock_add_listener, value) + await hass.async_block_till_done() + + state = hass.states.get("select.test_steering_wheel_heater") + assert state.state == expected + + async def test_export_rule_restore( hass: HomeAssistant, mock_site_info: AsyncMock, diff --git a/tests/components/teslemetry/test_update.py b/tests/components/teslemetry/test_update.py index af6c9d847f16da..6c3d48f61cdbb5 100644 --- a/tests/components/teslemetry/test_update.py +++ b/tests/components/teslemetry/test_update.py @@ -11,14 +11,14 @@ from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.components.teslemetry.update import INSTALLING from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL -from homeassistant.const import ATTR_ENTITY_ID, Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, Platform +from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from . import assert_entities, reload_platform, setup_platform from .const import COMMAND_OK, VEHICLE_DATA, VEHICLE_DATA_ALT -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, mock_restore_cache async def test_update( @@ -113,6 +113,8 @@ async def test_update_streaming( await hass.async_block_till_done() state = hass.states.get("update.test_update") + assert state.attributes["in_progress"] is True + assert state.attributes["update_percentage"] == 50 assert state == snapshot(name="downloading") mock_add_listener.send( @@ -130,6 +132,9 @@ async def test_update_streaming( ) await hass.async_block_till_done() state = hass.states.get("update.test_update") + # Install percentages up to 10% reflect Tesla's pre-installation step, not real progress + assert state.attributes["in_progress"] is False + assert state.attributes["update_percentage"] is None assert state == snapshot(name="ready") mock_add_listener.send( @@ -147,8 +152,30 @@ async def test_update_streaming( ) await hass.async_block_till_done() state = hass.states.get("update.test_update") + assert state.attributes["in_progress"] is True + assert state.attributes["update_percentage"] == 50 assert state == snapshot(name="installing") + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + Signal.SOFTWARE_UPDATE_VERSION: "2025.2.1", + Signal.VERSION: "2025.1.1", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_update") + # 100% installed is complete, not in progress + assert state.attributes["in_progress"] is False + assert state.attributes["update_percentage"] is None + assert state == snapshot(name="install_complete") + mock_add_listener.send( { "vin": VEHICLE_DATA_ALT["response"]["vin"], @@ -164,9 +191,193 @@ async def test_update_streaming( ) await hass.async_block_till_done() state = hass.states.get("update.test_update") + assert state.attributes["in_progress"] is False + assert state.attributes["update_percentage"] is None assert state == snapshot(name="updated") await reload_platform(hass, entry, [Platform.UPDATE]) state = hass.states.get("update.test_update") + assert state.attributes["in_progress"] is False + assert state.attributes["update_percentage"] is None assert state == snapshot(name="restored") + + +async def test_update_streaming_scheduled_not_clobbered( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Test that a scheduled install stays in progress until real progress or cancellation.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + await setup_platform(hass, [Platform.UPDATE]) + + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: 1735689600, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_update") + assert state.attributes["in_progress"] is True + assert state.attributes["update_percentage"] is None + assert state == snapshot(name="scheduled") + + # Download begins and the schedule clears in the same payload: progress must win + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 5, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_update") + assert state.attributes["in_progress"] is True + assert state.attributes["update_percentage"] == 5 + assert state == snapshot(name="downloading_after_schedule_cleared") + + +async def test_update_streaming_restore( + hass: HomeAssistant, + mock_vehicle_data: AsyncMock, +) -> None: + """Test that the streaming update entity restores update_percentage, not install_percentage.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + entity_id = "update.test_update" + mock_restore_cache( + hass, + ( + State( + entity_id, + STATE_ON, + attributes={ + "in_progress": True, + "update_percentage": 42, + "installed_version": "2025.1.1", + "latest_version": "2025.2.1", + }, + ), + ), + ) + + await setup_platform(hass, [Platform.UPDATE]) + + state = hass.states.get(entity_id) + assert state.attributes["in_progress"] is True + assert state.attributes["update_percentage"] == 42 + + +@pytest.mark.parametrize( + ("data", "expected_in_progress", "expected_percentage"), + [ + pytest.param( + { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 0, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + }, + False, + None, + id="download_0pct_is_idle", + ), + pytest.param( + { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 1, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + }, + True, + 1, + id="download_1pct_is_in_progress", + ), + pytest.param( + { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + }, + False, + None, + id="download_100pct_is_complete", + ), + pytest.param( + { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: 1, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + }, + False, + None, + id="install_1pct_is_not_in_progress", + ), + pytest.param( + { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: 10, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + }, + False, + None, + id="install_10pct_is_not_in_progress", + ), + pytest.param( + { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: 11, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + }, + True, + 11, + id="install_11pct_is_in_progress", + ), + pytest.param( + { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + }, + False, + None, + id="install_100pct_is_complete", + ), + ], +) +async def test_update_streaming_progress_thresholds( + hass: HomeAssistant, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, + data: dict[Signal, int | None], + expected_in_progress: bool, + expected_percentage: int | None, +) -> None: + """Test download/install progress threshold edge cases.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + await setup_platform(hass, [Platform.UPDATE]) + + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": data, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.attributes["in_progress"] is expected_in_progress + assert state.attributes["update_percentage"] == expected_percentage