diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index dd153299bce0c..2882f855a0d99 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -99,7 +99,7 @@ jobs: name: requirements_diff - name: Build wheels - uses: home-assistant/wheels@2023.09.1 + uses: home-assistant/wheels@2023.10.1 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -180,7 +180,7 @@ jobs: sed -i "/numpy/d" homeassistant/package_constraints.txt - name: Build wheels (part 1) - uses: home-assistant/wheels@2023.09.1 + uses: home-assistant/wheels@2023.10.1 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -194,7 +194,7 @@ jobs: requirements: "requirements_all.txtaa" - name: Build wheels (part 2) - uses: home-assistant/wheels@2023.09.1 + uses: home-assistant/wheels@2023.10.1 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -208,7 +208,7 @@ jobs: requirements: "requirements_all.txtab" - name: Build wheels (part 3) - uses: home-assistant/wheels@2023.09.1 + uses: home-assistant/wheels@2023.10.1 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 diff --git a/docs/source/api/util.rst b/docs/source/api/util.rst index 071f4d81cdf47..f670fc5220411 100644 --- a/docs/source/api/util.rst +++ b/docs/source/api/util.rst @@ -46,14 +46,6 @@ homeassistant.util.decorator :undoc-members: :show-inheritance: -homeassistant.util.distance ---------------------------- - -.. automodule:: homeassistant.util.distance - :members: - :undoc-members: - :show-inheritance: - homeassistant.util.dt --------------------- @@ -141,11 +133,3 @@ homeassistant.util.unit\_system :members: :undoc-members: :show-inheritance: - -homeassistant.util.volume -------------------------- - -.. automodule:: homeassistant.util.volume - :members: - :undoc-members: - :show-inheritance: diff --git a/homeassistant/components/abode/__init__.py b/homeassistant/components/abode/__init__.py index 490561c748548..4e4b6a9561dbe 100644 --- a/homeassistant/components/abode/__init__.py +++ b/homeassistant/components/abode/__init__.py @@ -1,6 +1,7 @@ """Support for the Abode Security System.""" from __future__ import annotations +from dataclasses import dataclass, field from functools import partial from jaraco.abode.automation import Automation as AbodeAuto @@ -25,7 +26,7 @@ EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, entity from homeassistant.helpers.device_registry import DeviceInfo @@ -71,15 +72,14 @@ ] +@dataclass class AbodeSystem: """Abode System class.""" - def __init__(self, abode: Abode, polling: bool) -> None: - """Initialize the system.""" - self.abode = abode - self.polling = polling - self.entity_ids: set[str | None] = set() - self.logout_listener = None + abode: Abode + polling: bool + entity_ids: set[str | None] = field(default_factory=set) + logout_listener: CALLBACK_TYPE | None = None async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 96872e039e1d5..1622f568a2db4 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -531,6 +531,7 @@ def async_write_ha_state(self) -> None: for unsub in self._alarm_unsubs: unsub() + self._alarm_unsubs.clear() now = dt_util.now() event = self.event @@ -540,6 +541,7 @@ def async_write_ha_state(self) -> None: @callback def update(_: datetime.datetime) -> None: """Run when the active or upcoming event starts or ends.""" + _LOGGER.debug("Running %s update", self.entity_id) self._async_write_ha_state() if now < event.start_datetime_local: @@ -553,6 +555,13 @@ def update(_: datetime.datetime) -> None: self._alarm_unsubs.append( async_track_point_in_time(self.hass, update, event.end_datetime_local) ) + _LOGGER.debug( + "Scheduled %d updates for %s (%s, %s)", + len(self._alarm_unsubs), + self.entity_id, + event.start_datetime_local, + event.end_datetime_local, + ) async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass. @@ -561,6 +570,7 @@ async def async_will_remove_from_hass(self) -> None: """ for unsub in self._alarm_unsubs: unsub() + self._alarm_unsubs.clear() async def async_get_events( self, diff --git a/homeassistant/components/co2signal/coordinator.py b/homeassistant/components/co2signal/coordinator.py index c210d989c0415..24d7bbd18af64 100644 --- a/homeassistant/components/co2signal/coordinator.py +++ b/homeassistant/components/co2signal/coordinator.py @@ -3,11 +3,11 @@ from collections.abc import Mapping from datetime import timedelta -from json import JSONDecodeError import logging from typing import Any, cast import CO2Signal +from requests.exceptions import JSONDecodeError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 0135fa3984a66..48478f075d327 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -6,10 +6,11 @@ from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import COVER, COVER_CLOSE, COVER_OPEN, COVER_STATUS -from homeassistant.components.cover import CoverDeviceClass, CoverEntity +from homeassistant.components.cover import STATE_CLOSED, CoverDeviceClass, CoverEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -32,7 +33,9 @@ async def async_setup_entry( ) -class ComelitCoverEntity(CoordinatorEntity[ComelitSerialBridge], CoverEntity): +class ComelitCoverEntity( + CoordinatorEntity[ComelitSerialBridge], RestoreEntity, CoverEntity +): """Cover device.""" _attr_device_class = CoverDeviceClass.SHUTTER @@ -51,8 +54,9 @@ def __init__( super().__init__(coordinator) self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" self._attr_device_info = coordinator.platform_device_info(device, COVER) - # Device doesn't provide a status so we assume CLOSE at startup - self._last_action = COVER_STATUS.index("closing") + # Device doesn't provide a status so we assume UNKNOWN at first startup + self._last_action: int | None = None + self._last_state: str | None = None def _current_action(self, action: str) -> bool: """Return the current cover action.""" @@ -67,12 +71,19 @@ def device_status(self) -> int: return self.coordinator.data[COVER][self._device.index].status @property - def is_closed(self) -> bool: - """Return True if cover is closed.""" + def is_closed(self) -> bool | None: + """Return if the cover is closed.""" + + if self._last_state in [None, "unknown"]: + return None + if self.device_status != COVER_STATUS.index("stopped"): return False - return bool(self._last_action == COVER_STATUS.index("closing")) + if self._last_action: + return self._last_action == COVER_STATUS.index("closing") + + return self._last_state == STATE_CLOSED @property def is_closing(self) -> bool: @@ -99,3 +110,17 @@ async def async_stop_cover(self, **_kwargs: Any) -> None: action = COVER_OPEN if self.is_closing else COVER_CLOSE await self._api.cover_move(self._device.index, action) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle device update.""" + self._last_state = self.state + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + + await super().async_added_to_hass() + + if last_state := await self.async_get_last_state(): + self._last_state = last_state.state diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index 6f536bf474439..5d057d40e1ba1 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -80,6 +80,7 @@ { vol.Required(CONF_COMMAND): cv.string, vol.Optional(CONF_NAME, default=BINARY_SENSOR_DEFAULT_NAME): cv.string, + vol.Optional(CONF_ICON): cv.template, vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_DEVICE_CLASS): BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, @@ -119,6 +120,7 @@ vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_JSON_ATTRIBUTES): cv.ensure_list_csv, vol.Optional(CONF_NAME, default=SENSOR_DEFAULT_NAME): cv.string, + vol.Optional(CONF_ICON): cv.template, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_UNIQUE_ID): cv.string, diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index 1d6ee9046e876..3ccd0bd15031f 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -16,6 +16,7 @@ from homeassistant.const import ( CONF_COMMAND, CONF_DEVICE_CLASS, + CONF_ICON, CONF_NAME, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, @@ -86,6 +87,7 @@ async def async_setup_platform( device_class: BinarySensorDeviceClass | None = binary_sensor_config.get( CONF_DEVICE_CLASS ) + icon: Template | None = binary_sensor_config.get(CONF_ICON) value_template: Template | None = binary_sensor_config.get(CONF_VALUE_TEMPLATE) command_timeout: int = binary_sensor_config[CONF_COMMAND_TIMEOUT] unique_id: str | None = binary_sensor_config.get(CONF_UNIQUE_ID) @@ -100,6 +102,7 @@ async def async_setup_platform( CONF_UNIQUE_ID: unique_id, CONF_NAME: Template(name, hass), CONF_DEVICE_CLASS: device_class, + CONF_ICON: icon, } async_add_entities( diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index b3c36ed39d2b8..0ba8caed6c585 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/denonavr", "iot_class": "local_push", "loggers": ["denonavr"], - "requirements": ["denonavr==0.11.3"], + "requirements": ["denonavr==0.11.4"], "ssdp": [ { "manufacturer": "Denon", diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index 51ede0d65b4ce..8b6907a60f7ad 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -8,7 +8,15 @@ from typing import Any, Concatenate, ParamSpec, TypeVar from denonavr import DenonAVR -from denonavr.const import POWER_ON, STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING +from denonavr.const import ( + ALL_TELNET_EVENTS, + ALL_ZONES, + POWER_ON, + STATE_OFF, + STATE_ON, + STATE_PAUSED, + STATE_PLAYING, +) from denonavr.exceptions import ( AvrCommandError, AvrForbiddenError, @@ -73,6 +81,23 @@ SERVICE_SET_DYNAMIC_EQ = "set_dynamic_eq" SERVICE_UPDATE_AUDYSSEY = "update_audyssey" +# HA Telnet events +TELNET_EVENTS = { + "HD", + "MS", + "MU", + "MV", + "NS", + "NSE", + "PS", + "SI", + "SS", + "TF", + "ZM", + "Z2", + "Z3", +} + _DenonDeviceT = TypeVar("_DenonDeviceT", bound="DenonDevice") _R = TypeVar("_R") _P = ParamSpec("_P") @@ -254,7 +279,9 @@ def __init__( async def _telnet_callback(self, zone, event, parameter) -> None: """Process a telnet command callback.""" # There are multiple checks implemented which reduce unnecessary updates of the ha state machine - if zone != self._receiver.zone: + if zone not in (self._receiver.zone, ALL_ZONES): + return + if event not in TELNET_EVENTS: return # Some updates trigger multiple events like one for artist and one for title for one change # We skip every event except the last one @@ -268,11 +295,11 @@ async def _telnet_callback(self, zone, event, parameter) -> None: async def async_added_to_hass(self) -> None: """Register for telnet events.""" - self._receiver.register_callback("ALL", self._telnet_callback) + self._receiver.register_callback(ALL_TELNET_EVENTS, self._telnet_callback) async def async_will_remove_from_hass(self) -> None: """Clean up the entity.""" - self._receiver.unregister_callback("ALL", self._telnet_callback) + self._receiver.unregister_callback(ALL_TELNET_EVENTS, self._telnet_callback) @async_log_errors async def async_update(self) -> None: diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index d6fdd971fa637..8169eeb70e3c5 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==17.0.0", + "aioesphomeapi==17.0.1", "bluetooth-data-tools==1.12.0", "esphome-dashboard-api==1.2.3" ], diff --git a/homeassistant/components/fitbit/__init__.py b/homeassistant/components/fitbit/__init__.py index 04946f6386f38..522754de5d6fa 100644 --- a/homeassistant/components/fitbit/__init__.py +++ b/homeassistant/components/fitbit/__init__.py @@ -1 +1,53 @@ """The fitbit component.""" + +from http import HTTPStatus + +import aiohttp + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_entry_oauth2_flow + +from . import api +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up fitbit from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + fitbit_api = api.OAuthFitbitApi( + hass, session, unit_system=entry.data.get("unit_system") + ) + try: + await fitbit_api.async_get_access_token() + except aiohttp.ClientResponseError as err: + if err.status == HTTPStatus.UNAUTHORIZED: + raise ConfigEntryAuthFailed from err + raise ConfigEntryNotReady from err + except aiohttp.ClientError as err: + raise ConfigEntryNotReady from err + + hass.data[DOMAIN][entry.entry_id] = fitbit_api + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/fitbit/api.py b/homeassistant/components/fitbit/api.py index bf2874712929f..9ebfbcf71883d 100644 --- a/homeassistant/components/fitbit/api.py +++ b/homeassistant/components/fitbit/api.py @@ -1,11 +1,14 @@ """API for fitbit bound to Home Assistant OAuth.""" +from abc import ABC, abstractmethod import logging from typing import Any, cast from fitbit import Fitbit +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.util.unit_system import METRIC_SYSTEM from .const import FitbitUnitSystem @@ -13,32 +16,50 @@ _LOGGER = logging.getLogger(__name__) +CONF_REFRESH_TOKEN = "refresh_token" +CONF_EXPIRES_AT = "expires_at" -class FitbitApi: - """Fitbit client library wrapper base class.""" + +class FitbitApi(ABC): + """Fitbit client library wrapper base class. + + This can be subclassed with different implementations for providing an access + token depending on the use case. + """ def __init__( self, hass: HomeAssistant, - client: Fitbit, unit_system: FitbitUnitSystem | None = None, ) -> None: """Initialize Fitbit auth.""" self._hass = hass self._profile: FitbitProfile | None = None - self._client = client self._unit_system = unit_system - @property - def client(self) -> Fitbit: - """Property to expose the underlying client library.""" - return self._client + @abstractmethod + async def async_get_access_token(self) -> dict[str, Any]: + """Return a valid token dictionary for the Fitbit API.""" + + async def _async_get_client(self) -> Fitbit: + """Get synchronous client library, called before each client request.""" + # Always rely on Home Assistant's token update mechanism which refreshes + # the data in the configuration entry. + token = await self.async_get_access_token() + return Fitbit( + client_id=None, + client_secret=None, + access_token=token[CONF_ACCESS_TOKEN], + refresh_token=token[CONF_REFRESH_TOKEN], + expires_at=float(token[CONF_EXPIRES_AT]), + ) async def async_get_user_profile(self) -> FitbitProfile: """Return the user profile from the API.""" if self._profile is None: + client = await self._async_get_client() response: dict[str, Any] = await self._hass.async_add_executor_job( - self._client.user_profile_get + client.user_profile_get ) _LOGGER.debug("user_profile_get=%s", response) profile = response["user"] @@ -73,8 +94,9 @@ async def async_get_unit_system(self) -> FitbitUnitSystem: async def async_get_devices(self) -> list[FitbitDevice]: """Return available devices.""" + client = await self._async_get_client() devices: list[dict[str, str]] = await self._hass.async_add_executor_job( - self._client.get_devices + client.get_devices ) _LOGGER.debug("get_devices=%s", devices) return [ @@ -90,17 +112,56 @@ async def async_get_devices(self) -> list[FitbitDevice]: async def async_get_latest_time_series(self, resource_type: str) -> dict[str, Any]: """Return the most recent value from the time series for the specified resource type.""" + client = await self._async_get_client() # Set request header based on the configured unit system - self._client.system = await self.async_get_unit_system() + client.system = await self.async_get_unit_system() def _time_series() -> dict[str, Any]: - return cast( - dict[str, Any], self._client.time_series(resource_type, period="7d") - ) + return cast(dict[str, Any], client.time_series(resource_type, period="7d")) response: dict[str, Any] = await self._hass.async_add_executor_job(_time_series) _LOGGER.debug("time_series(%s)=%s", resource_type, response) key = resource_type.replace("/", "-") dated_results: list[dict[str, Any]] = response[key] return dated_results[-1] + + +class OAuthFitbitApi(FitbitApi): + """Provide fitbit authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + hass: HomeAssistant, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + unit_system: FitbitUnitSystem | None = None, + ) -> None: + """Initialize OAuthFitbitApi.""" + super().__init__(hass, unit_system) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> dict[str, Any]: + """Return a valid access token for the Fitbit API.""" + if not self._oauth_session.valid_token: + await self._oauth_session.async_ensure_token_valid() + return self._oauth_session.token + + +class ConfigFlowFitbitApi(FitbitApi): + """Profile fitbit authentication before a ConfigEntry exists. + + This implementation directly provides the token without supporting refresh. + """ + + def __init__( + self, + hass: HomeAssistant, + token: dict[str, Any], + ) -> None: + """Initialize ConfigFlowFitbitApi.""" + super().__init__(hass) + self._token = token + + async def async_get_access_token(self) -> dict[str, Any]: + """Return the token for the Fitbit API.""" + return self._token diff --git a/homeassistant/components/fitbit/application_credentials.py b/homeassistant/components/fitbit/application_credentials.py new file mode 100644 index 0000000000000..95a7cf799bfba --- /dev/null +++ b/homeassistant/components/fitbit/application_credentials.py @@ -0,0 +1,77 @@ +"""application_credentials platform the fitbit integration. + +See https://dev.fitbit.com/build/reference/web-api/authorization/ for additional +details on Fitbit authorization. +""" + +import base64 +import logging +from typing import Any, cast + +from homeassistant.components.application_credentials import ( + AuthImplementation, + AuthorizationServer, + ClientCredential, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, OAUTH2_AUTHORIZE, OAUTH2_TOKEN + +_LOGGER = logging.getLogger(__name__) + + +class FitbitOAuth2Implementation(AuthImplementation): + """Local OAuth2 implementation for Fitbit. + + This implementation is needed to send the client id and secret as a Basic + Authorization header. + """ + + async def async_resolve_external_data(self, external_data: dict[str, Any]) -> dict: + """Resolve the authorization code to tokens.""" + session = async_get_clientsession(self.hass) + data = { + "grant_type": "authorization_code", + "code": external_data["code"], + "redirect_uri": external_data["state"]["redirect_uri"], + } + resp = await session.post(self.token_url, data=data, headers=self._headers) + resp.raise_for_status() + return cast(dict, await resp.json()) + + async def _token_request(self, data: dict) -> dict: + """Make a token request.""" + session = async_get_clientsession(self.hass) + body = { + **data, + CONF_CLIENT_ID: self.client_id, + CONF_CLIENT_SECRET: self.client_secret, + } + resp = await session.post(self.token_url, data=body, headers=self._headers) + resp.raise_for_status() + return cast(dict, await resp.json()) + + @property + def _headers(self) -> dict[str, str]: + """Build necessary authorization headers.""" + basic_auth = base64.b64encode( + f"{self.client_id}:{self.client_secret}".encode() + ).decode() + return {"Authorization": f"Basic {basic_auth}"} + + +async def async_get_auth_implementation( + hass: HomeAssistant, auth_domain: str, credential: ClientCredential +) -> config_entry_oauth2_flow.AbstractOAuth2Implementation: + """Return a custom auth implementation.""" + return FitbitOAuth2Implementation( + hass, + auth_domain, + credential, + AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ), + ) diff --git a/homeassistant/components/fitbit/config_flow.py b/homeassistant/components/fitbit/config_flow.py new file mode 100644 index 0000000000000..ff9cf6cd17c5c --- /dev/null +++ b/homeassistant/components/fitbit/config_flow.py @@ -0,0 +1,80 @@ +"""Config flow for fitbit.""" + +from collections.abc import Mapping +import logging +from typing import Any + +from fitbit.exceptions import HTTPException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_entry_oauth2_flow + +from . import api +from .const import DOMAIN, OAUTH_SCOPES + +_LOGGER = logging.getLogger(__name__) + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle fitbit OAuth2 authentication.""" + + DOMAIN = DOMAIN + + reauth_entry: ConfigEntry | None = None + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, str]: + """Extra data that needs to be appended to the authorize url.""" + return { + "scope": " ".join(OAUTH_SCOPES), + "prompt": "consent" if not self.reauth_entry else "none", + } + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: + """Create an entry for the flow, or update existing entry.""" + + client = api.ConfigFlowFitbitApi(self.hass, data[CONF_TOKEN]) + try: + profile = await client.async_get_user_profile() + except HTTPException as err: + _LOGGER.error("Failed to fetch user profile for Fitbit API: %s", err) + return self.async_abort(reason="cannot_connect") + + if self.reauth_entry: + if self.reauth_entry.unique_id != profile.encoded_id: + return self.async_abort(reason="wrong_account") + self.hass.config_entries.async_update_entry(self.reauth_entry, data=data) + await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + await self.async_set_unique_id(profile.encoded_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=profile.full_name, data=data) + + async def async_step_import(self, data: dict[str, Any]) -> FlowResult: + """Handle import from YAML.""" + return await self.async_oauth_create_entry(data) diff --git a/homeassistant/components/fitbit/const.py b/homeassistant/components/fitbit/const.py index 19734add07a1d..9c77ea79a4f1b 100644 --- a/homeassistant/components/fitbit/const.py +++ b/homeassistant/components/fitbit/const.py @@ -65,3 +65,16 @@ class FitbitUnitSystem(StrEnum): EN_GB = "en_GB" """Use United Kingdom units.""" + + +OAUTH2_AUTHORIZE = "https://www.fitbit.com/oauth2/authorize" +OAUTH2_TOKEN = "https://api.fitbit.com/oauth2/token" +OAUTH_SCOPES = [ + "activity", + "heartrate", + "nutrition", + "profile", + "settings", + "sleep", + "weight", +] diff --git a/homeassistant/components/fitbit/manifest.json b/homeassistant/components/fitbit/manifest.json index 510fe8da90017..7739c7237f0e3 100644 --- a/homeassistant/components/fitbit/manifest.json +++ b/homeassistant/components/fitbit/manifest.json @@ -2,7 +2,8 @@ "domain": "fitbit", "name": "Fitbit", "codeowners": ["@allenporter"], - "dependencies": ["configurator", "http"], + "config_flow": true, + "dependencies": ["application_credentials", "http"], "documentation": "https://www.home-assistant.io/integrations/fitbit", "iot_class": "cloud_polling", "loggers": ["fitbit"], diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index e08f56e0e3407..8fbd9a25474bd 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -7,17 +7,14 @@ import datetime import logging import os -import time from typing import Any, Final, cast -from aiohttp.web import Request -from fitbit import Fitbit -from fitbit.api import FitbitOauth2Client -from oauthlib.oauth2.rfc6749.errors import MismatchingStateError, MissingTokenError import voluptuous as vol -from homeassistant.components import configurator -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.components.sensor import ( PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, SensorDeviceClass, @@ -25,9 +22,11 @@ SensorEntityDescription, SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, + CONF_TOKEN, CONF_UNIT_SYSTEM, PERCENTAGE, UnitOfLength, @@ -35,11 +34,11 @@ UnitOfTime, ) from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level -from homeassistant.helpers.json import save_json -from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.json import load_json_object @@ -54,8 +53,7 @@ CONF_MONITORED_RESOURCES, DEFAULT_CLOCK_FORMAT, DEFAULT_CONFIG, - FITBIT_AUTH_CALLBACK_PATH, - FITBIT_AUTH_START, + DOMAIN, FITBIT_CONFIG_FILE, FITBIT_DEFAULT_RESOURCES, FitbitUnitSystem, @@ -129,6 +127,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): unit_type: str | None = None value_fn: Callable[[dict[str, Any]], Any] = _default_value_fn unit_fn: Callable[[FitbitUnitSystem], str | None] = lambda x: None + scope: str | None = None FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( @@ -137,18 +136,22 @@ class FitbitSensorEntityDescription(SensorEntityDescription): name="Activity Calories", native_unit_of_measurement="cal", icon="mdi:fire", + scope="activity", ), FitbitSensorEntityDescription( key="activities/calories", name="Calories", native_unit_of_measurement="cal", icon="mdi:fire", + scope="activity", ), FitbitSensorEntityDescription( key="activities/caloriesBMR", name="Calories BMR", native_unit_of_measurement="cal", icon="mdi:fire", + scope="activity", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="activities/distance", @@ -157,6 +160,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.DISTANCE, value_fn=_distance_value_fn, unit_fn=_distance_unit, + scope="activity", ), FitbitSensorEntityDescription( key="activities/elevation", @@ -164,12 +168,14 @@ class FitbitSensorEntityDescription(SensorEntityDescription): icon="mdi:walk", device_class=SensorDeviceClass.DISTANCE, unit_fn=_elevation_unit, + scope="activity", ), FitbitSensorEntityDescription( key="activities/floors", name="Floors", native_unit_of_measurement="floors", icon="mdi:walk", + scope="activity", ), FitbitSensorEntityDescription( key="activities/heart", @@ -177,6 +183,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement="bpm", icon="mdi:heart-pulse", value_fn=lambda result: int(result["value"]["restingHeartRate"]), + scope="heartrate", ), FitbitSensorEntityDescription( key="activities/minutesFairlyActive", @@ -184,6 +191,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:walk", device_class=SensorDeviceClass.DURATION, + scope="activity", ), FitbitSensorEntityDescription( key="activities/minutesLightlyActive", @@ -191,6 +199,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:walk", device_class=SensorDeviceClass.DURATION, + scope="activity", ), FitbitSensorEntityDescription( key="activities/minutesSedentary", @@ -198,6 +207,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:seat-recline-normal", device_class=SensorDeviceClass.DURATION, + scope="activity", ), FitbitSensorEntityDescription( key="activities/minutesVeryActive", @@ -205,24 +215,30 @@ class FitbitSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:run", device_class=SensorDeviceClass.DURATION, + scope="activity", ), FitbitSensorEntityDescription( key="activities/steps", name="Steps", native_unit_of_measurement="steps", icon="mdi:walk", + scope="activity", ), FitbitSensorEntityDescription( key="activities/tracker/activityCalories", name="Tracker Activity Calories", native_unit_of_measurement="cal", icon="mdi:fire", + scope="activity", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="activities/tracker/calories", name="Tracker Calories", native_unit_of_measurement="cal", icon="mdi:fire", + scope="activity", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="activities/tracker/distance", @@ -231,6 +247,8 @@ class FitbitSensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.DISTANCE, value_fn=_distance_value_fn, unit_fn=_distance_unit, + scope="activity", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="activities/tracker/elevation", @@ -238,12 +256,16 @@ class FitbitSensorEntityDescription(SensorEntityDescription): icon="mdi:walk", device_class=SensorDeviceClass.DISTANCE, unit_fn=_elevation_unit, + scope="activity", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="activities/tracker/floors", name="Tracker Floors", native_unit_of_measurement="floors", icon="mdi:walk", + scope="activity", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="activities/tracker/minutesFairlyActive", @@ -251,6 +273,8 @@ class FitbitSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:walk", device_class=SensorDeviceClass.DURATION, + scope="activity", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="activities/tracker/minutesLightlyActive", @@ -258,6 +282,8 @@ class FitbitSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:walk", device_class=SensorDeviceClass.DURATION, + scope="activity", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="activities/tracker/minutesSedentary", @@ -265,6 +291,8 @@ class FitbitSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:seat-recline-normal", device_class=SensorDeviceClass.DURATION, + scope="activity", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="activities/tracker/minutesVeryActive", @@ -272,12 +300,16 @@ class FitbitSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:run", device_class=SensorDeviceClass.DURATION, + scope="activity", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="activities/tracker/steps", name="Tracker Steps", native_unit_of_measurement="steps", icon="mdi:walk", + scope="activity", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="body/bmi", @@ -286,6 +318,8 @@ class FitbitSensorEntityDescription(SensorEntityDescription): icon="mdi:human", state_class=SensorStateClass.MEASUREMENT, value_fn=_body_value_fn, + scope="weight", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="body/fat", @@ -294,6 +328,8 @@ class FitbitSensorEntityDescription(SensorEntityDescription): icon="mdi:human", state_class=SensorStateClass.MEASUREMENT, value_fn=_body_value_fn, + scope="weight", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="body/weight", @@ -303,12 +339,14 @@ class FitbitSensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.WEIGHT, value_fn=_body_value_fn, unit_fn=_weight_unit, + scope="weight", ), FitbitSensorEntityDescription( key="sleep/awakeningsCount", name="Awakenings Count", native_unit_of_measurement="times awaken", icon="mdi:sleep", + scope="sleep", ), FitbitSensorEntityDescription( key="sleep/efficiency", @@ -316,6 +354,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=PERCENTAGE, icon="mdi:sleep", state_class=SensorStateClass.MEASUREMENT, + scope="sleep", ), FitbitSensorEntityDescription( key="sleep/minutesAfterWakeup", @@ -323,6 +362,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, + scope="sleep", ), FitbitSensorEntityDescription( key="sleep/minutesAsleep", @@ -330,6 +370,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, + scope="sleep", ), FitbitSensorEntityDescription( key="sleep/minutesAwake", @@ -337,6 +378,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, + scope="sleep", ), FitbitSensorEntityDescription( key="sleep/minutesToFallAsleep", @@ -344,6 +386,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, + scope="sleep", ), FitbitSensorEntityDescription( key="sleep/timeInBed", @@ -351,6 +394,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:hotel", device_class=SensorDeviceClass.DURATION, + scope="sleep", ), ) @@ -359,18 +403,21 @@ class FitbitSensorEntityDescription(SensorEntityDescription): key="sleep/startTime", name="Sleep Start Time", icon="mdi:clock", + scope="sleep", ) SLEEP_START_TIME_12HR = FitbitSensorEntityDescription( key="sleep/startTime", name="Sleep Start Time", icon="mdi:clock", value_fn=_clock_format_12h, + scope="sleep", ) FITBIT_RESOURCE_BATTERY = FitbitSensorEntityDescription( key="devices/battery", name="Battery", icon="mdi:battery", + scope="settings", ) FITBIT_RESOURCES_KEYS: Final[list[str]] = [ @@ -397,88 +444,29 @@ class FitbitSensorEntityDescription(SensorEntityDescription): } ) +# Only import configuration if it was previously created successfully with all +# of the following fields. +FITBIT_CONF_KEYS = [ + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + ATTR_ACCESS_TOKEN, + ATTR_REFRESH_TOKEN, + ATTR_LAST_SAVED_AT, +] -def request_app_setup( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - config_path: str, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Assist user with configuring the Fitbit dev application.""" - - def fitbit_configuration_callback(fields: list[dict[str, str]]) -> None: - """Handle configuration updates.""" - config_path = hass.config.path(FITBIT_CONFIG_FILE) - if os.path.isfile(config_path): - config_file = load_json_object(config_path) - if config_file == DEFAULT_CONFIG: - error_msg = ( - f"You didn't correctly modify {FITBIT_CONFIG_FILE}, please try" - " again." - ) - - configurator.notify_errors(hass, _CONFIGURING["fitbit"], error_msg) - else: - setup_platform(hass, config, add_entities, discovery_info) - else: - setup_platform(hass, config, add_entities, discovery_info) - - try: - description = f"""Please create a Fitbit developer app at - https://dev.fitbit.com/apps/new. - For the OAuth 2.0 Application Type choose Personal. - Set the Callback URL to {get_url(hass, require_ssl=True)}{FITBIT_AUTH_CALLBACK_PATH}. - (Note: Your Home Assistant instance must be accessible via HTTPS.) - They will provide you a Client ID and secret. - These need to be saved into the file located at: {config_path}. - Then come back here and hit the below button. - """ - except NoURLAvailableError: - _LOGGER.error( - "Could not find an SSL enabled URL for your Home Assistant instance. " - "Fitbit requires that your Home Assistant instance is accessible via HTTPS" - ) - return - - submit = f"I have saved my Client ID and Client Secret into {FITBIT_CONFIG_FILE}." - - _CONFIGURING["fitbit"] = configurator.request_config( - hass, - "Fitbit", - fitbit_configuration_callback, - description=description, - submit_caption=submit, - description_image="/static/images/config_fitbit_app.png", - ) - - -def request_oauth_completion(hass: HomeAssistant) -> None: - """Request user complete Fitbit OAuth2 flow.""" - if "fitbit" in _CONFIGURING: - configurator.notify_errors( - hass, _CONFIGURING["fitbit"], "Failed to register, please try again." - ) - - return - - def fitbit_configuration_callback(fields: list[dict[str, str]]) -> None: - """Handle configuration updates.""" - - start_url = f"{get_url(hass, require_ssl=True)}{FITBIT_AUTH_START}" - - description = f"Please authorize Fitbit by visiting {start_url}" - _CONFIGURING["fitbit"] = configurator.request_config( - hass, - "Fitbit", - fitbit_configuration_callback, - description=description, - submit_caption="I have authorized Fitbit.", - ) +def load_config_file(config_path: str) -> dict[str, Any] | None: + """Load existing valid fitbit.conf from disk for import.""" + if os.path.isfile(config_path): + config_file = load_json_object(config_path) + if config_file != DEFAULT_CONFIG and all( + key in config_file for key in FITBIT_CONF_KEYS + ): + return config_file + return None -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, @@ -486,182 +474,119 @@ def setup_platform( ) -> None: """Set up the Fitbit sensor.""" config_path = hass.config.path(FITBIT_CONFIG_FILE) - if os.path.isfile(config_path): - config_file = load_json_object(config_path) - if config_file == DEFAULT_CONFIG: - request_app_setup( - hass, config, add_entities, config_path, discovery_info=None - ) - return - else: - save_json(config_path, DEFAULT_CONFIG) - request_app_setup(hass, config, add_entities, config_path, discovery_info=None) - return - - if "fitbit" in _CONFIGURING: - configurator.request_done(hass, _CONFIGURING.pop("fitbit")) - - if ( - (access_token := config_file.get(ATTR_ACCESS_TOKEN)) is not None - and (refresh_token := config_file.get(ATTR_REFRESH_TOKEN)) is not None - and (expires_at := config_file.get(ATTR_LAST_SAVED_AT)) is not None - ): - authd_client = Fitbit( - config_file.get(CONF_CLIENT_ID), - config_file.get(CONF_CLIENT_SECRET), - access_token=access_token, - refresh_token=refresh_token, - expires_at=expires_at, - refresh_cb=lambda x: None, + config_file = await hass.async_add_executor_job(load_config_file, config_path) + _LOGGER.debug("loaded config file: %s", config_file) + + if config_file is not None: + _LOGGER.debug("Importing existing fitbit.conf application credentials") + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential( + config_file[CONF_CLIENT_ID], config_file[CONF_CLIENT_SECRET] + ), ) - - if int(time.time()) - cast(int, expires_at) > 3600: - authd_client.client.refresh_token() - - api = FitbitApi(hass, authd_client, config[CONF_UNIT_SYSTEM]) - user_profile = asyncio.run_coroutine_threadsafe( - api.async_get_user_profile(), hass.loop - ).result() - unit_system = asyncio.run_coroutine_threadsafe( - api.async_get_unit_system(), hass.loop - ).result() - - clock_format = config[CONF_CLOCK_FORMAT] - monitored_resources = config[CONF_MONITORED_RESOURCES] - resource_list = [ - *FITBIT_RESOURCES_LIST, - SLEEP_START_TIME_12HR if clock_format == "12H" else SLEEP_START_TIME, - ] - entities = [ - FitbitSensor( - api, - user_profile.encoded_id, - config_path, - description, - units=description.unit_fn(unit_system), - ) - for description in resource_list - if description.key in monitored_resources - ] - if "devices/battery" in monitored_resources: - devices = asyncio.run_coroutine_threadsafe( - api.async_get_devices(), - hass.loop, - ).result() - entities.extend( - [ - FitbitSensor( - api, - user_profile.encoded_id, - config_path, - FITBIT_RESOURCE_BATTERY, - device, - ) - for device in devices - ] - ) - add_entities(entities, True) - - else: - oauth = FitbitOauth2Client( - config_file.get(CONF_CLIENT_ID), config_file.get(CONF_CLIENT_SECRET) - ) - - redirect_uri = f"{get_url(hass, require_ssl=True)}{FITBIT_AUTH_CALLBACK_PATH}" - - fitbit_auth_start_url, _ = oauth.authorize_token_url( - redirect_uri=redirect_uri, - scope=[ - "activity", - "heartrate", - "nutrition", - "profile", - "settings", - "sleep", - "weight", - ], + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + "auth_implementation": DOMAIN, + CONF_TOKEN: { + ATTR_ACCESS_TOKEN: config_file[ATTR_ACCESS_TOKEN], + ATTR_REFRESH_TOKEN: config_file[ATTR_REFRESH_TOKEN], + "expires_at": config_file[ATTR_LAST_SAVED_AT], + }, + CONF_CLOCK_FORMAT: config[CONF_CLOCK_FORMAT], + CONF_UNIT_SYSTEM: config[CONF_UNIT_SYSTEM], + CONF_MONITORED_RESOURCES: config[CONF_MONITORED_RESOURCES], + }, ) + translation_key = "deprecated_yaml_import" + if ( + result.get("type") == FlowResultType.ABORT + and result.get("reason") == "cannot_connect" + ): + translation_key = "deprecated_yaml_import_issue_cannot_connect" + else: + translation_key = "deprecated_yaml_no_import" - hass.http.register_redirect(FITBIT_AUTH_START, fitbit_auth_start_url) - hass.http.register_view(FitbitAuthCallbackView(config, add_entities, oauth)) + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2024.5.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=translation_key, + ) - request_oauth_completion(hass) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Fitbit sensor platform.""" -class FitbitAuthCallbackView(HomeAssistantView): - """Handle OAuth finish callback requests.""" + api: FitbitApi = hass.data[DOMAIN][entry.entry_id] - requires_auth = False - url = FITBIT_AUTH_CALLBACK_PATH - name = "api:fitbit:callback" + # Note: This will only be one rpc since it will cache the user profile + (user_profile, unit_system) = await asyncio.gather( + api.async_get_user_profile(), api.async_get_unit_system() + ) - def __init__( - self, - config: ConfigType, - add_entities: AddEntitiesCallback, - oauth: FitbitOauth2Client, - ) -> None: - """Initialize the OAuth callback view.""" - self.config = config - self.add_entities = add_entities - self.oauth = oauth - - async def get(self, request: Request) -> str: - """Finish OAuth callback request.""" - hass: HomeAssistant = request.app["hass"] - data = request.query - - response_message = """Fitbit has been successfully authorized! - You can close this window now!""" - - result = None - if data.get("code") is not None: - redirect_uri = f"{get_url(hass, require_current_request=True)}{FITBIT_AUTH_CALLBACK_PATH}" - - try: - result = await hass.async_add_executor_job( - self.oauth.fetch_access_token, data.get("code"), redirect_uri + clock_format = entry.data.get(CONF_CLOCK_FORMAT) + + # Originally entities were configured explicitly from yaml config. Newer + # configurations will infer which entities to enable based on the allowed + # scopes the user selected during OAuth. When creating entities based on + # scopes, some entities are disabled by default. + monitored_resources = entry.data.get(CONF_MONITORED_RESOURCES) + scopes = entry.data["token"].get("scope", "").split(" ") + + def is_explicit_enable(description: FitbitSensorEntityDescription) -> bool: + """Determine if entity is enabled by default.""" + if monitored_resources is not None: + return description.key in monitored_resources + return False + + def is_allowed_resource(description: FitbitSensorEntityDescription) -> bool: + """Determine if an entity is allowed to be created.""" + if is_explicit_enable(description): + return True + return description.scope in scopes + + resource_list = [ + *FITBIT_RESOURCES_LIST, + SLEEP_START_TIME_12HR if clock_format == "12H" else SLEEP_START_TIME, + ] + + entities = [ + FitbitSensor( + api, + user_profile.encoded_id, + description, + units=description.unit_fn(unit_system), + enable_default_override=is_explicit_enable(description), + ) + for description in resource_list + if is_allowed_resource(description) + ] + if is_allowed_resource(FITBIT_RESOURCE_BATTERY): + devices = await api.async_get_devices() + entities.extend( + [ + FitbitSensor( + api, + user_profile.encoded_id, + FITBIT_RESOURCE_BATTERY, + device=device, + enable_default_override=is_explicit_enable(FITBIT_RESOURCE_BATTERY), ) - except MissingTokenError as error: - _LOGGER.error("Missing token: %s", error) - response_message = f"""Something went wrong when - attempting authenticating with Fitbit. The error - encountered was {error}. Please try again!""" - except MismatchingStateError as error: - _LOGGER.error("Mismatched state, CSRF error: %s", error) - response_message = f"""Something went wrong when - attempting authenticating with Fitbit. The error - encountered was {error}. Please try again!""" - else: - _LOGGER.error("Unknown error when authing") - response_message = """Something went wrong when - attempting authenticating with Fitbit. - An unknown error occurred. Please try again! - """ - - if result is None: - _LOGGER.error("Unknown error when authing") - response_message = """Something went wrong when - attempting authenticating with Fitbit. - An unknown error occurred. Please try again! - """ - - html_response = f"""Fitbit Auth -

{response_message}

""" - - if result: - config_contents = { - ATTR_ACCESS_TOKEN: result.get("access_token"), - ATTR_REFRESH_TOKEN: result.get("refresh_token"), - CONF_CLIENT_ID: self.oauth.client_id, - CONF_CLIENT_SECRET: self.oauth.client_secret, - ATTR_LAST_SAVED_AT: int(time.time()), - } - save_json(hass.config.path(FITBIT_CONFIG_FILE), config_contents) - - hass.async_add_job(setup_platform, hass, self.config, self.add_entities) - - return html_response + for device in devices + ] + ) + async_add_entities(entities, True) class FitbitSensor(SensorEntity): @@ -674,15 +599,14 @@ def __init__( self, api: FitbitApi, user_profile_id: str, - config_path: str, description: FitbitSensorEntityDescription, device: FitbitDevice | None = None, units: str | None = None, + enable_default_override: bool = False, ) -> None: """Initialize the Fitbit sensor.""" self.entity_description = description self.api = api - self.config_path = config_path self.device = device self._attr_unique_id = f"{user_profile_id}_{description.key}" @@ -693,6 +617,9 @@ def __init__( if units is not None: self._attr_native_unit_of_measurement = units + if enable_default_override: + self._attr_entity_registry_enabled_default = True + @property def icon(self) -> str | None: """Icon to use in the frontend, if any.""" @@ -730,16 +657,3 @@ async def async_update(self) -> None: else: result = await self.api.async_get_latest_time_series(resource_type) self._attr_native_value = self.entity_description.value_fn(result) - - self.hass.async_add_executor_job(self._update_token) - - def _update_token(self) -> None: - token = self.api.client.client.session.token - config_contents = { - ATTR_ACCESS_TOKEN: token.get("access_token"), - ATTR_REFRESH_TOKEN: token.get("refresh_token"), - CONF_CLIENT_ID: self.api.client.client.client_id, - CONF_CLIENT_SECRET: self.api.client.client.client_secret, - ATTR_LAST_SAVED_AT: int(time.time()), - } - save_json(self.config_path, config_contents) diff --git a/homeassistant/components/fitbit/strings.json b/homeassistant/components/fitbit/strings.json new file mode 100644 index 0000000000000..2d74408a73f1c --- /dev/null +++ b/homeassistant/components/fitbit/strings.json @@ -0,0 +1,44 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "auth": { + "title": "Link Fitbit" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Fitbit integration needs to re-authenticate your account" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "wrong_account": "The user credentials provided do not match this Fitbit account." + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + }, + "issues": { + "deprecated_yaml_no_import": { + "title": "Fitbit YAML configuration is being removed", + "description": "Configuring Fitbit using YAML is being removed.\n\nRemove the `fitbit` configuration from your configuration.yaml file and remove fitbit.conf if it exists and restart Home Assistant and [set up the integration](/config/integrations/dashboard/add?domain=fitbit) manually." + }, + "deprecated_yaml_import": { + "title": "Fitbit YAML configuration is being removed", + "description": "Configuring Fitbit using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically, including OAuth Application Credentials.\n\nRemove the `fitbit` configuration from your configuration.yaml file and remove fitbit.conf and restart Home Assistant to fix this issue." + }, + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The Fitbit YAML configuration import failed", + "description": "Configuring Fitbit using YAML is being removed but there was a connection error importing your YAML configuration.\n\nRestart Home Assistant to try again or remove the Fitbit YAML configuration from your configuration.yaml file and remove the fitbit.conf and continue to [set up the integration](/config/integrations/dashboard/add?domain=fitbit) manually." + } + } +} diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index c04575d80052c..40cf1e18b0ea5 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -885,7 +885,7 @@ async def async_step_flashing_complete( async def check_multi_pan_addon(hass: HomeAssistant) -> None: - """Check the multi-PAN addon state, and start it if installed but not started. + """Check the multiprotocol addon state, and start it if installed but not started. Does nothing if Hass.io is not loaded. Raises on error or if the add-on is installed but not started. diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 5f17069f5d5e0..218e0c3e88de6 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -45,7 +45,7 @@ async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: return hw_discovery_data = { - "name": "SkyConnect Multi-PAN", + "name": "SkyConnect Multiprotocol", "port": { "path": get_zigbee_socket(), }, diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index 5ac44f3f290d3..fce731777b1af 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -76,7 +76,7 @@ async def _async_zha_physical_discovery(self) -> dict[str, Any]: def _zha_name(self) -> str: """Return the ZHA name.""" - return "SkyConnect Multi-PAN" + return "SkyConnect Multiprotocol" def _hardware_name(self) -> str: """Return the name of the hardware.""" diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index 30015d1bae448..b61e01061c3d9 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -35,7 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hw_discovery_data = ZHA_HW_DISCOVERY_DATA else: hw_discovery_data = { - "name": "Yellow Multi-PAN", + "name": "Yellow Multiprotocol", "port": { "path": get_zigbee_socket(), }, diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index 8be7b8a4ff7a3..667b8f3d97ae3 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -153,7 +153,7 @@ async def _async_zha_physical_discovery(self) -> dict[str, Any]: def _zha_name(self) -> str: """Return the ZHA name.""" - return "Yellow Multi-PAN" + return "Yellow Multiprotocol" def _hardware_name(self) -> str: """Return the name of the hardware.""" diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index 561a24c29dfe7..cb0ba4522bf34 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/mill", "iot_class": "local_polling", "loggers": ["mill", "mill_local"], - "requirements": ["millheater==0.11.5", "mill-local==0.3.0"] + "requirements": ["millheater==0.11.6", "mill-local==0.3.0"] } diff --git a/homeassistant/components/owntracks/strings.json b/homeassistant/components/owntracks/strings.json index 2486e01223ffc..499b598d7ae6d 100644 --- a/homeassistant/components/owntracks/strings.json +++ b/homeassistant/components/owntracks/strings.json @@ -11,7 +11,7 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "create_entry": { - "default": "\n\nOn Android, open [the OwnTracks app]({android_url}), go to Preferences > Connection. Change the following settings:\n - Mode: HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: `''`\n - Device ID: `''`\n\nOn iOS, open [the OwnTracks app]({ios_url}), tap (i) icon in top left > Settings. Change the following settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: `''`\n\n{secret}\n\nSee [the documentation]({docs_url}) for more information." + "default": "\n\nOn Android, open [the OwnTracks app]({android_url}), go to Preferences > Connection. Change the following settings:\n - Mode: HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: `'(Your name)'`\n - Device ID: `'(Your device name)'`\n\nOn iOS, open [the OwnTracks app]({ios_url}), tap (i) icon in top left > Settings. Change the following settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: `'(Your name)'`\n\n{secret}\n\nSee [the documentation]({docs_url}) for more information." } } } diff --git a/homeassistant/components/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py index b5886011ea3ed..3333d8bc4cb88 100644 --- a/homeassistant/components/rainbird/binary_sensor.py +++ b/homeassistant/components/rainbird/binary_sensor.py @@ -48,8 +48,11 @@ def __init__( """Initialize the Rain Bird sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.serial_number}-{description.key}" - self._attr_device_info = coordinator.device_info + if coordinator.unique_id: + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + self._attr_device_info = coordinator.device_info + else: + self._attr_name = f"{coordinator.device_name} Rainsensor" @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/rainbird/calendar.py b/homeassistant/components/rainbird/calendar.py index 4d8cc38c8bf84..356f7d7cc4e4c 100644 --- a/homeassistant/components/rainbird/calendar.py +++ b/homeassistant/components/rainbird/calendar.py @@ -34,8 +34,9 @@ async def async_setup_entry( [ RainBirdCalendarEntity( data.schedule_coordinator, - data.coordinator.serial_number, + data.coordinator.unique_id, data.coordinator.device_info, + data.coordinator.device_name, ) ] ) @@ -47,20 +48,24 @@ class RainBirdCalendarEntity( """A calendar event entity.""" _attr_has_entity_name = True - _attr_name = None + _attr_name: str | None = None _attr_icon = "mdi:sprinkler" def __init__( self, coordinator: RainbirdScheduleUpdateCoordinator, - serial_number: str, - device_info: DeviceInfo, + unique_id: str | None, + device_info: DeviceInfo | None, + device_name: str, ) -> None: """Create the Calendar event device.""" super().__init__(coordinator) self._event: CalendarEvent | None = None - self._attr_unique_id = serial_number - self._attr_device_info = device_info + if unique_id: + self._attr_unique_id = unique_id + self._attr_device_info = device_info + else: + self._attr_name = device_name @property def event(self) -> CalendarEvent | None: diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index 5c40ef808b20c..763e50fe5d995 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -21,7 +21,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER, TIMEOUT_SECONDS +from .const import DOMAIN, MANUFACTURER, TIMEOUT_SECONDS UPDATE_INTERVAL = datetime.timedelta(minutes=1) # The calendar data requires RPCs for each program/zone, and the data rarely @@ -51,7 +51,7 @@ def __init__( hass: HomeAssistant, name: str, controller: AsyncRainbirdController, - serial_number: str, + unique_id: str | None, model_info: ModelAndVersion, ) -> None: """Initialize RainbirdUpdateCoordinator.""" @@ -62,7 +62,7 @@ def __init__( update_interval=UPDATE_INTERVAL, ) self._controller = controller - self._serial_number = serial_number + self._unique_id = unique_id self._zones: set[int] | None = None self._model_info = model_info @@ -72,16 +72,23 @@ def controller(self) -> AsyncRainbirdController: return self._controller @property - def serial_number(self) -> str: - """Return the device serial number.""" - return self._serial_number + def unique_id(self) -> str | None: + """Return the config entry unique id.""" + return self._unique_id @property - def device_info(self) -> DeviceInfo: + def device_name(self) -> str: + """Device name for the rainbird controller.""" + return f"{MANUFACTURER} Controller" + + @property + def device_info(self) -> DeviceInfo | None: """Return information about the device.""" + if not self._unique_id: + return None return DeviceInfo( - name=f"{MANUFACTURER} Controller", - identifiers={(DOMAIN, self._serial_number)}, + name=self.device_name, + identifiers={(DOMAIN, self._unique_id)}, manufacturer=MANUFACTURER, model=self._model_info.model_name, sw_version=f"{self._model_info.major}.{self._model_info.minor}", @@ -164,7 +171,7 @@ def coordinator(self) -> RainbirdUpdateCoordinator: self.hass, name=self.entry.title, controller=self.controller, - serial_number=self.entry.data[CONF_SERIAL_NUMBER], + unique_id=self.entry.unique_id, model_info=self.model_info, ) diff --git a/homeassistant/components/rainbird/number.py b/homeassistant/components/rainbird/number.py index d0945609a1bcf..1e72fabafcd91 100644 --- a/homeassistant/components/rainbird/number.py +++ b/homeassistant/components/rainbird/number.py @@ -51,8 +51,11 @@ def __init__( ) -> None: """Initialize the Rain Bird sensor.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.serial_number}-rain-delay" - self._attr_device_info = coordinator.device_info + if coordinator.unique_id: + self._attr_unique_id = f"{coordinator.unique_id}-rain-delay" + self._attr_device_info = coordinator.device_info + else: + self._attr_name = f"{coordinator.device_name} Rain delay" @property def native_value(self) -> float | None: diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index 32eb053f47844..d44e7156cb50b 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -52,8 +52,13 @@ def __init__( """Initialize the Rain Bird sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.serial_number}-{description.key}" - self._attr_device_info = coordinator.device_info + if coordinator.unique_id: + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + self._attr_device_info = coordinator.device_info + else: + self._attr_name = ( + f"{coordinator.device_name} {description.key.capitalize()}" + ) @property def native_value(self) -> StateType: diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index cafc541d860c2..62b3b0e9a8c28 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -65,20 +65,23 @@ def __init__( """Initialize a Rain Bird Switch Device.""" super().__init__(coordinator) self._zone = zone + if coordinator.unique_id: + self._attr_unique_id = f"{coordinator.unique_id}-{zone}" + device_name = f"{MANUFACTURER} Sprinkler {zone}" if imported_name: self._attr_name = imported_name self._attr_has_entity_name = False else: - self._attr_name = None + self._attr_name = None if coordinator.unique_id else device_name self._attr_has_entity_name = True self._duration_minutes = duration_minutes - self._attr_unique_id = f"{coordinator.serial_number}-{zone}" - self._attr_device_info = DeviceInfo( - name=f"{MANUFACTURER} Sprinkler {zone}", - identifiers={(DOMAIN, self._attr_unique_id)}, - manufacturer=MANUFACTURER, - via_device=(DOMAIN, coordinator.serial_number), - ) + if coordinator.unique_id and self._attr_unique_id: + self._attr_device_info = DeviceInfo( + name=device_name, + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer=MANUFACTURER, + via_device=(DOMAIN, coordinator.unique_id), + ) @property def extra_state_attributes(self): diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index fa5c0dd70950a..831b64f7056f4 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -3,7 +3,6 @@ from functools import partial import logging -import re from typing import Any from libsoundtouch.device import SoundTouchDevice @@ -250,7 +249,7 @@ def play_media( ) -> None: """Play a piece of media.""" _LOGGER.debug("Starting media with media_id: %s", media_id) - if re.match(r"http?://", str(media_id)): + if str(media_id).lower().startswith("http://"): # no https support # URL _LOGGER.debug("Playing URL %s", str(media_id)) self._device.play_url(str(media_id)) diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index bf66ef729bd3b..5768f886adbcf 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -4,14 +4,13 @@ import asyncio from collections import deque from collections.abc import Callable, Coroutine, Iterable -from dataclasses import dataclass +from dataclasses import dataclass, field import datetime from enum import IntEnum import logging from typing import TYPE_CHECKING, Any from aiohttp import web -import attr import numpy as np from homeassistant.components.http.view import HomeAssistantView @@ -51,15 +50,15 @@ class Orientation(IntEnum): ROTATE_RIGHT = 8 -@attr.s(slots=True) +@dataclass(slots=True) class StreamSettings: """Stream settings.""" - ll_hls: bool = attr.ib() - min_segment_duration: float = attr.ib() - part_target_duration: float = attr.ib() - hls_advance_part_limit: int = attr.ib() - hls_part_timeout: float = attr.ib() + ll_hls: bool + min_segment_duration: float + part_target_duration: float + hls_advance_part_limit: int + hls_part_timeout: float STREAM_SETTINGS_NON_LL_HLS = StreamSettings( @@ -81,29 +80,29 @@ class Part: data: bytes -@attr.s(slots=True) +@dataclass(slots=True) class Segment: """Represent a segment.""" - sequence: int = attr.ib() + sequence: int # the init of the mp4 the segment is based on - init: bytes = attr.ib() + init: bytes # For detecting discontinuities across stream restarts - stream_id: int = attr.ib() - start_time: datetime.datetime = attr.ib() - _stream_outputs: Iterable[StreamOutput] = attr.ib() - duration: float = attr.ib(default=0) - parts: list[Part] = attr.ib(factory=list) + stream_id: int + start_time: datetime.datetime + _stream_outputs: Iterable[StreamOutput] + duration: float = 0 + parts: list[Part] = field(default_factory=list) # Store text of this segment's hls playlist for reuse # Use list[str] for easy appends - hls_playlist_template: list[str] = attr.ib(factory=list) - hls_playlist_parts: list[str] = attr.ib(factory=list) + hls_playlist_template: list[str] = field(default_factory=list) + hls_playlist_parts: list[str] = field(default_factory=list) # Number of playlist parts rendered so far - hls_num_parts_rendered: int = attr.ib(default=0) + hls_num_parts_rendered: int = 0 # Set to true when all the parts are rendered - hls_playlist_complete: bool = attr.ib(default=False) + hls_playlist_complete: bool = False - def __attrs_post_init__(self) -> None: + def __post_init__(self) -> None: """Run after init.""" for output in self._stream_outputs: output.put(self) diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index cc4970c8a5e72..3d27637c9890c 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -4,13 +4,13 @@ from collections import defaultdict, deque from collections.abc import Callable, Generator, Iterator, Mapping import contextlib +from dataclasses import fields import datetime from io import SEEK_END, BytesIO import logging from threading import Event from typing import Any, Self, cast -import attr import av from homeassistant.core import HomeAssistant @@ -283,7 +283,7 @@ def create_segment(self) -> None: init=read_init(self._memory_file), # Fetch the latest StreamOutputs, which may have changed since the # worker started. - stream_outputs=self._stream_state.outputs, + _stream_outputs=self._stream_state.outputs, start_time=self._start_time, ) self._memory_file_pos = self._memory_file.tell() @@ -537,7 +537,7 @@ def stream_worker( audio_stream = None # Disable ll-hls for hls inputs if container.format.name == "hls": - for field in attr.fields(StreamSettings): + for field in fields(StreamSettings): setattr( stream_settings, field.name, diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index e685d1de80661..e835a2f4acabc 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.40.0"] + "requirements": ["PySwitchbot==0.40.1"] } diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index 62170b329f490..e0ecf5827d807 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -24,6 +24,7 @@ from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -154,12 +155,18 @@ class WaqiSensor(CoordinatorEntity[WAQIDataUpdateCoordinator], SensorEntity): _attr_icon = ATTR_ICON _attr_device_class = SensorDeviceClass.AQI _attr_state_class = SensorStateClass.MEASUREMENT + _attr_has_entity_name = True + _attr_name = None def __init__(self, coordinator: WAQIDataUpdateCoordinator) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._attr_name = f"WAQI {self.coordinator.data.city.name}" self._attr_unique_id = f"{coordinator.data.station_id}_air_quality" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(coordinator.data.station_id))}, + name=coordinator.data.city.name, + entry_type=DeviceEntryType.SERVICE, + ) @property def native_value(self) -> int | None: diff --git a/homeassistant/components/weatherkit/__init__.py b/homeassistant/components/weatherkit/__init__.py index fb41ffc108440..15ad5fa2ffb74 100644 --- a/homeassistant/components/weatherkit/__init__.py +++ b/homeassistant/components/weatherkit/__init__.py @@ -23,7 +23,7 @@ ) from .coordinator import WeatherKitDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.WEATHER] +PLATFORMS: list[Platform] = [Platform.WEATHER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/weatherkit/const.py b/homeassistant/components/weatherkit/const.py index 590ca65c9a9e2..e35dd33c56127 100644 --- a/homeassistant/components/weatherkit/const.py +++ b/homeassistant/components/weatherkit/const.py @@ -10,7 +10,13 @@ "https://developer.apple.com/weatherkit/data-source-attribution/" ) +MANUFACTURER = "Apple Weather" + CONF_KEY_ID = "key_id" CONF_SERVICE_ID = "service_id" CONF_TEAM_ID = "team_id" CONF_KEY_PEM = "key_pem" + +ATTR_CURRENT_WEATHER = "currentWeather" +ATTR_FORECAST_HOURLY = "forecastHourly" +ATTR_FORECAST_DAILY = "forecastDaily" diff --git a/homeassistant/components/weatherkit/entity.py b/homeassistant/components/weatherkit/entity.py new file mode 100644 index 0000000000000..a244c9c452543 --- /dev/null +++ b/homeassistant/components/weatherkit/entity.py @@ -0,0 +1,33 @@ +"""Base entity for weatherkit.""" + +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import WeatherKitDataUpdateCoordinator + + +class WeatherKitEntity(Entity): + """Base entity for all WeatherKit platforms.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: WeatherKitDataUpdateCoordinator, unique_id_suffix: str | None + ) -> None: + """Initialize the entity with device info and unique ID.""" + config_data = coordinator.config_entry.data + + config_entry_unique_id = ( + f"{config_data[CONF_LATITUDE]}-{config_data[CONF_LONGITUDE]}" + ) + self._attr_unique_id = config_entry_unique_id + if unique_id_suffix is not None: + self._attr_unique_id += f"_{unique_id_suffix}" + + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, config_entry_unique_id)}, + manufacturer=MANUFACTURER, + ) diff --git a/homeassistant/components/weatherkit/sensor.py b/homeassistant/components/weatherkit/sensor.py new file mode 100644 index 0000000000000..38b4a60cba525 --- /dev/null +++ b/homeassistant/components/weatherkit/sensor.py @@ -0,0 +1,73 @@ +"""WeatherKit sensors.""" + + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfVolumetricFlux +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTR_CURRENT_WEATHER, DOMAIN +from .coordinator import WeatherKitDataUpdateCoordinator +from .entity import WeatherKitEntity + +SENSORS = ( + SensorEntityDescription( + key="precipitationIntensity", + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + ), + SensorEntityDescription( + key="pressureTrend", + device_class=SensorDeviceClass.ENUM, + icon="mdi:gauge", + options=["rising", "falling", "steady"], + translation_key="pressure_trend", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add sensor entities from a config_entry.""" + coordinator: WeatherKitDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] + + async_add_entities( + WeatherKitSensor(coordinator, description) for description in SENSORS + ) + + +class WeatherKitSensor( + CoordinatorEntity[WeatherKitDataUpdateCoordinator], WeatherKitEntity, SensorEntity +): + """WeatherKit sensor entity.""" + + def __init__( + self, + coordinator: WeatherKitDataUpdateCoordinator, + entity_description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + WeatherKitEntity.__init__( + self, coordinator, unique_id_suffix=entity_description.key + ) + self.entity_description = entity_description + + @property + def native_value(self) -> StateType: + """Return native value from coordinator current weather.""" + return self.coordinator.data[ATTR_CURRENT_WEATHER][self.entity_description.key] diff --git a/homeassistant/components/weatherkit/strings.json b/homeassistant/components/weatherkit/strings.json index 4581028f209e9..a0b62a5e16fe9 100644 --- a/homeassistant/components/weatherkit/strings.json +++ b/homeassistant/components/weatherkit/strings.json @@ -21,5 +21,17 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "entity": { + "sensor": { + "pressure_trend": { + "name": "Pressure trend", + "state": { + "steady": "Steady", + "rising": "Rising", + "falling": "Falling" + } + } + } } } diff --git a/homeassistant/components/weatherkit/weather.py b/homeassistant/components/weatherkit/weather.py index ce997fa500f22..98816d520ba30 100644 --- a/homeassistant/components/weatherkit/weather.py +++ b/homeassistant/components/weatherkit/weather.py @@ -23,19 +23,23 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_LATITUDE, - CONF_LONGITUDE, UnitOfLength, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTRIBUTION, DOMAIN +from .const import ( + ATTR_CURRENT_WEATHER, + ATTR_FORECAST_DAILY, + ATTR_FORECAST_HOURLY, + ATTRIBUTION, + DOMAIN, +) from .coordinator import WeatherKitDataUpdateCoordinator +from .entity import WeatherKitEntity async def async_setup_entry( @@ -121,13 +125,12 @@ def _map_hourly_forecast(forecast: dict[str, Any]) -> Forecast: class WeatherKitWeather( - SingleCoordinatorWeatherEntity[WeatherKitDataUpdateCoordinator] + SingleCoordinatorWeatherEntity[WeatherKitDataUpdateCoordinator], WeatherKitEntity ): """Weather entity for Apple WeatherKit integration.""" _attr_attribution = ATTRIBUTION - _attr_has_entity_name = True _attr_name = None _attr_native_temperature_unit = UnitOfTemperature.CELSIUS @@ -140,17 +143,9 @@ def __init__( self, coordinator: WeatherKitDataUpdateCoordinator, ) -> None: - """Initialise the platform with a data instance and site.""" + """Initialize the platform with a coordinator.""" super().__init__(coordinator) - config_data = coordinator.config_entry.data - self._attr_unique_id = ( - f"{config_data[CONF_LATITUDE]}-{config_data[CONF_LONGITUDE]}" - ) - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self._attr_unique_id)}, - manufacturer="Apple Weather", - ) + WeatherKitEntity.__init__(self, coordinator, unique_id_suffix=None) @property def supported_features(self) -> WeatherEntityFeature: @@ -174,7 +169,7 @@ def data(self) -> dict[str, Any]: @property def current_weather(self) -> dict[str, Any]: """Return current weather data.""" - return self.data["currentWeather"] + return self.data[ATTR_CURRENT_WEATHER] @property def condition(self) -> str | None: @@ -245,7 +240,7 @@ def wind_bearing(self) -> float | None: @callback def _async_forecast_daily(self) -> list[Forecast] | None: """Return the daily forecast.""" - daily_forecast = self.data.get("forecastDaily") + daily_forecast = self.data.get(ATTR_FORECAST_DAILY) if not daily_forecast: return None @@ -255,7 +250,7 @@ def _async_forecast_daily(self) -> list[Forecast] | None: @callback def _async_forecast_hourly(self) -> list[Forecast] | None: """Return the hourly forecast.""" - hourly_forecast = self.data.get("forecastHourly") + hourly_forecast = self.data.get(ATTR_FORECAST_HOURLY) if not hourly_forecast: return None diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 44d32b0603c1b..246bcc134d026 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -41,7 +41,14 @@ from homeassistant.helpers.typing import ConfigType from .api import ConfigEntryWithingsApi -from .const import CONF_CLOUDHOOK_URL, CONF_PROFILES, CONF_USE_WEBHOOK, DOMAIN, LOGGER +from .const import ( + CONF_CLOUDHOOK_URL, + CONF_PROFILES, + CONF_USE_WEBHOOK, + DEFAULT_TITLE, + DOMAIN, + LOGGER, +) from .coordinator import WithingsDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -151,10 +158,14 @@ async def register_webhook( ) return + webhook_name = "Withings" + if entry.title != DEFAULT_TITLE: + webhook_name = " ".join([DEFAULT_TITLE, entry.title]) + webhook_register( hass, DOMAIN, - "Withings", + webhook_name, entry.data[CONF_WEBHOOK_ID], get_webhook_handler(coordinator), ) diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index 38ea7d4653753..e65896cdd4200 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -136,3 +136,8 @@ def update_entity_state(self, state: dict[str, Any]) -> None: state.get(self.entity_description.state_key) ) self.async_write_ha_state() + + @property + def available(self) -> bool: + """Return true is device is available.""" + return super().available and self.coordinator.dev_online diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 935889a0368ec..9fc4dac8ada51 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -8,3 +8,4 @@ ATTR_DEVICE_STATE = "state" ATTR_DEVICE_ID = "deviceId" YOLINK_EVENT = f"{DOMAIN}_event" +YOLINK_OFFLINE_TIME = 32400 diff --git a/homeassistant/components/yolink/coordinator.py b/homeassistant/components/yolink/coordinator.py index 9055b2d044e26..f2c942caab954 100644 --- a/homeassistant/components/yolink/coordinator.py +++ b/homeassistant/components/yolink/coordinator.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from datetime import timedelta +from datetime import UTC, datetime, timedelta import logging from yolink.device import YoLinkDevice @@ -12,7 +12,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ATTR_DEVICE_STATE, DOMAIN +from .const import ATTR_DEVICE_STATE, DOMAIN, YOLINK_OFFLINE_TIME _LOGGER = logging.getLogger(__name__) @@ -37,6 +37,7 @@ def __init__( ) self.device = device self.paired_device = paired_device + self.dev_online = True async def _async_update_data(self) -> dict: """Fetch device state.""" @@ -44,6 +45,13 @@ async def _async_update_data(self) -> dict: async with asyncio.timeout(10): device_state_resp = await self.device.fetch_state() device_state = device_state_resp.data.get(ATTR_DEVICE_STATE) + device_reporttime = device_state_resp.data.get("reportAt") + if device_reporttime is not None: + rpt_time_delta = ( + datetime.now(tz=UTC).replace(tzinfo=None) + - datetime.strptime(device_reporttime, "%Y-%m-%dT%H:%M:%S.%fZ") + ).total_seconds() + self.dev_online = rpt_time_delta < YOLINK_OFFLINE_TIME if self.paired_device is not None and device_state is not None: paried_device_state_resp = await self.paired_device.fetch_state() paried_device_state = paried_device_state_resp.data.get( diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 451b486acd21b..2fc4a2b07251f 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -261,3 +261,8 @@ def update_entity_state(self, state: dict) -> None: return self._attr_native_value = attr_val self.async_write_ha_state() + + @property + def available(self) -> bool: + """Return true is device is available.""" + return super().available and self.coordinator.dev_online diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 9898c6a3496c5..53475588cfe84 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.115.0"] + "requirements": ["zeroconf==0.115.1"] } diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index b9a266304060f..c9decc92a6720 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -915,6 +915,7 @@ async def disconnect_client(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" info = hass.data[DOMAIN][entry.entry_id] + client: ZwaveClient = info[DATA_CLIENT] driver_events: DriverEvents = info[DATA_DRIVER_EVENTS] tasks: list[Coroutine] = [ @@ -925,8 +926,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = all(await asyncio.gather(*tasks)) if tasks else True - if hasattr(driver_events, "driver"): - await async_disable_server_logging_if_needed(hass, entry, driver_events.driver) + if client.connected and client.driver: + await async_disable_server_logging_if_needed(hass, entry, client.driver) if DATA_CLIENT_LISTEN_TASK in info: await disconnect_client(hass, entry) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 3e8a5e4f7570e..505196c43eb74 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["zwave_js_server"], "quality_scale": "platinum", - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.52.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.52.1"], "usb": [ { "vid": "0658", diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 8c9e3a57ddc23..a4db1b4c0de4b 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -5,6 +5,7 @@ APPLICATION_CREDENTIALS = [ "electric_kiwi", + "fitbit", "geocaching", "google", "google_assistant_sdk", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ef22ac4f6533a..b9e1fcf5259d9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -143,6 +143,7 @@ "fibaro", "filesize", "fireservicerota", + "fitbit", "fivem", "fjaraskupan", "flick_electric", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1d9c2208ad0bd..253669edf7d02 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1733,7 +1733,7 @@ "fitbit": { "name": "Fitbit", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "fivem": { diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index a4018101d0e3f..eed57e7ea25fe 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -599,7 +599,7 @@ def string(value: Any) -> str: def string_with_no_html(value: Any) -> str: """Validate that the value is a string without HTML.""" value = string(value) - regex = re.compile(r"<[a-z][\s\S]*>") + regex = re.compile(r"<[a-z].*?>", re.IGNORECASE) if regex.search(value): raise vol.Invalid("the string should not contain HTML") return str(value) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 42de474921515..bd3077c1d597e 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1323,12 +1323,18 @@ async def async_migrate_entries( config_entry_id: str, entry_callback: Callable[[RegistryEntry], dict[str, Any] | None], ) -> None: - """Migrator of unique IDs.""" + """Migrate entity registry entries which belong to a config entry. + + Can be used as a migrator of unique_ids or to update other entity registry data. + Can also be used to remove duplicated entity registry entries. + """ ent_reg = async_get(hass) - for entry in ent_reg.entities.values(): + for entry in list(ent_reg.entities.values()): if entry.config_entry_id != config_entry_id: continue + if not ent_reg.entities.get_entry(entry.id): + continue updates = entry_callback(entry) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d6f923f004723..659caa1078dc5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -52,7 +52,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.2 -zeroconf==0.115.0 +zeroconf==0.115.1 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/homeassistant/util/distance.py b/homeassistant/util/distance.py deleted file mode 100644 index 45b105aea9fb1..0000000000000 --- a/homeassistant/util/distance.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Distance util functions.""" -from __future__ import annotations - -from collections.abc import Callable - -# pylint: disable-next=hass-deprecated-import -from homeassistant.const import ( # noqa: F401 - LENGTH, - LENGTH_CENTIMETERS, - LENGTH_FEET, - LENGTH_INCHES, - LENGTH_KILOMETERS, - LENGTH_METERS, - LENGTH_MILES, - LENGTH_MILLIMETERS, - LENGTH_YARD, - UNIT_NOT_RECOGNIZED_TEMPLATE, -) -from homeassistant.helpers.frame import report - -from .unit_conversion import DistanceConverter - -VALID_UNITS = DistanceConverter.VALID_UNITS - -TO_METERS: dict[str, Callable[[float], float]] = { - LENGTH_METERS: lambda meters: meters, - LENGTH_MILES: lambda miles: miles * 1609.344, - LENGTH_YARD: lambda yards: yards * 0.9144, - LENGTH_FEET: lambda feet: feet * 0.3048, - LENGTH_INCHES: lambda inches: inches * 0.0254, - LENGTH_KILOMETERS: lambda kilometers: kilometers * 1000, - LENGTH_CENTIMETERS: lambda centimeters: centimeters * 0.01, - LENGTH_MILLIMETERS: lambda millimeters: millimeters * 0.001, -} - -METERS_TO: dict[str, Callable[[float], float]] = { - LENGTH_METERS: lambda meters: meters, - LENGTH_MILES: lambda meters: meters * 0.000621371, - LENGTH_YARD: lambda meters: meters * 1.09361, - LENGTH_FEET: lambda meters: meters * 3.28084, - LENGTH_INCHES: lambda meters: meters * 39.3701, - LENGTH_KILOMETERS: lambda meters: meters * 0.001, - LENGTH_CENTIMETERS: lambda meters: meters * 100, - LENGTH_MILLIMETERS: lambda meters: meters * 1000, -} - - -def convert(value: float, from_unit: str, to_unit: str) -> float: - """Convert one unit of measurement to another.""" - report( - ( - "uses distance utility. This is deprecated since 2022.10 and will " - "stop working in Home Assistant 2023.4, it should be updated to use " - "unit_conversion.DistanceConverter instead" - ), - error_if_core=False, - ) - return DistanceConverter.convert(value, from_unit, to_unit) diff --git a/homeassistant/util/volume.py b/homeassistant/util/volume.py deleted file mode 100644 index 8aae8ff104e65..0000000000000 --- a/homeassistant/util/volume.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Volume conversion util functions.""" -from __future__ import annotations - -# pylint: disable-next=hass-deprecated-import -from homeassistant.const import ( # noqa: F401 - UNIT_NOT_RECOGNIZED_TEMPLATE, - VOLUME, - VOLUME_CUBIC_FEET, - VOLUME_CUBIC_METERS, - VOLUME_FLUID_OUNCE, - VOLUME_GALLONS, - VOLUME_LITERS, - VOLUME_MILLILITERS, -) -from homeassistant.helpers.frame import report - -from .unit_conversion import VolumeConverter - -VALID_UNITS = VolumeConverter.VALID_UNITS - - -def liter_to_gallon(liter: float) -> float: - """Convert a volume measurement in Liter to Gallon.""" - return convert(liter, VOLUME_LITERS, VOLUME_GALLONS) - - -def gallon_to_liter(gallon: float) -> float: - """Convert a volume measurement in Gallon to Liter.""" - return convert(gallon, VOLUME_GALLONS, VOLUME_LITERS) - - -def cubic_meter_to_cubic_feet(cubic_meter: float) -> float: - """Convert a volume measurement in cubic meter to cubic feet.""" - return convert(cubic_meter, VOLUME_CUBIC_METERS, VOLUME_CUBIC_FEET) - - -def cubic_feet_to_cubic_meter(cubic_feet: float) -> float: - """Convert a volume measurement in cubic feet to cubic meter.""" - return convert(cubic_feet, VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS) - - -def convert(volume: float, from_unit: str, to_unit: str) -> float: - """Convert a volume from one unit to another.""" - report( - ( - "uses volume utility. This is deprecated since 2022.10 and will " - "stop working in Home Assistant 2023.4, it should be updated to use " - "unit_conversion.VolumeConverter instead" - ), - error_if_core=False, - ) - return VolumeConverter.convert(volume, from_unit, to_unit) diff --git a/requirements_all.txt b/requirements_all.txt index df05ddc12a69f..5693910f5b4cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -97,7 +97,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.40.0 +PySwitchbot==0.40.1 # homeassistant.components.switchmate PySwitchmate==0.5.1 @@ -231,7 +231,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==17.0.0 +aioesphomeapi==17.0.1 # homeassistant.components.flo aioflo==2021.11.0 @@ -668,7 +668,7 @@ deluge-client==1.7.1 demetriek==0.4.0 # homeassistant.components.denonavr -denonavr==0.11.3 +denonavr==0.11.4 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 @@ -1222,7 +1222,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.5 +millheater==0.11.6 # homeassistant.components.minio minio==7.1.12 @@ -2784,7 +2784,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.115.0 +zeroconf==0.115.1 # homeassistant.components.zeversolar zeversolar==0.3.1 @@ -2817,7 +2817,7 @@ zigpy==0.57.2 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.52.0 +zwave-js-server-python==0.52.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fddeddd33c091..7d9ba5b3634e5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -87,7 +87,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.40.0 +PySwitchbot==0.40.1 # homeassistant.components.syncthru PySyncThru==0.7.10 @@ -212,7 +212,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==17.0.0 +aioesphomeapi==17.0.1 # homeassistant.components.flo aioflo==2021.11.0 @@ -545,7 +545,7 @@ deluge-client==1.7.1 demetriek==0.4.0 # homeassistant.components.denonavr -denonavr==0.11.3 +denonavr==0.11.4 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 @@ -948,7 +948,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.5 +millheater==0.11.6 # homeassistant.components.minio minio==7.1.12 @@ -2078,7 +2078,7 @@ yt-dlp==2023.9.24 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.115.0 +zeroconf==0.115.1 # homeassistant.components.zeversolar zeversolar==0.3.1 @@ -2102,7 +2102,7 @@ zigpy-znp==0.11.5 zigpy==0.57.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.52.0 +zwave-js-server-python==0.52.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/rootfs/etc/services.d/home-assistant/finish b/rootfs/etc/services.d/home-assistant/finish index 057957a9c03ec..ae5b17e171a79 100755 --- a/rootfs/etc/services.d/home-assistant/finish +++ b/rootfs/etc/services.d/home-assistant/finish @@ -18,13 +18,11 @@ elif [[ ${APP_EXIT_CODE} -eq ${SIGNAL_EXIT_CODE} ]]; then NEW_EXIT_CODE=$((128 + SIGNAL_NO)) echo ${NEW_EXIT_CODE} > /run/s6-linux-init-container-results/exitcode - - if [[ ${SIGNAL_NO} -eq ${SIGTERM} ]]; then - /run/s6/basedir/bin/halt - fi else bashio::log.info "Home Assistant Core service shutdown" echo ${APP_EXIT_CODE} > /run/s6-linux-init-container-results/exitcode - /run/s6/basedir/bin/halt fi + +# Make sure to stop the container +/run/s6/basedir/bin/halt diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py index 7d5db4603fe31..360c78dd5a7a5 100644 --- a/tests/components/command_line/test_binary_sensor.py +++ b/tests/components/command_line/test_binary_sensor.py @@ -93,6 +93,9 @@ async def test_setup_integration_yaml( "payload_on": "1.0", "payload_off": "0", "value_template": "{{ value | multiply(0.1) }}", + "icon": ( + '{% if this.state=="on" %} mdi:on {% else %} mdi:off {% endif %}' + ), } } ] @@ -105,6 +108,7 @@ async def test_template(hass: HomeAssistant, load_yaml_integration: None) -> Non entity_state = hass.states.get("binary_sensor.test") assert entity_state assert entity_state.state == STATE_ON + assert entity_state.attributes.get("icon") == "mdi:on" @pytest.mark.parametrize( @@ -304,7 +308,7 @@ async def _async_update(self) -> None: await hass.async_block_till_done() assert called - called.clear + called.clear() await hass.services.async_call( HA_DOMAIN, diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index da2bf1f6dd966..388d0345cad43 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -93,6 +93,7 @@ async def test_setup_integration_yaml( "command": "echo 50", "unit_of_measurement": "in", "value_template": "{{ value | multiply(0.1) }}", + "icon": "mdi:console", } } ] @@ -105,6 +106,7 @@ async def test_template(hass: HomeAssistant, load_yaml_integration: None) -> Non entity_state = hass.states.get("sensor.test") assert entity_state assert float(entity_state.state) == 5 + assert entity_state.attributes.get("icon") == "mdi:console" @pytest.mark.parametrize( diff --git a/tests/components/devolo_home_network/test_image.py b/tests/components/devolo_home_network/test_image.py index b8fb491e1ec5d..ef7c4b2bbba90 100644 --- a/tests/components/devolo_home_network/test_image.py +++ b/tests/components/devolo_home_network/test_image.py @@ -68,8 +68,8 @@ async def test_guest_wifi_qr( # Emulate device failure mock_device.device.async_get_wifi_guest_access.side_effect = DeviceUnavailable() - freezer.move_to(dt_util.utcnow() + SHORT_UPDATE_INTERVAL) - async_fire_time_changed(hass, dt_util.utcnow() + SHORT_UPDATE_INTERVAL) + freezer.tick(SHORT_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -80,8 +80,8 @@ async def test_guest_wifi_qr( mock_device.device.async_get_wifi_guest_access = AsyncMock( return_value=GUEST_WIFI_CHANGED ) - freezer.move_to(dt_util.utcnow() + SHORT_UPDATE_INTERVAL) - async_fire_time_changed(hass, dt_util.utcnow() + SHORT_UPDATE_INTERVAL) + freezer.tick(SHORT_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) diff --git a/tests/components/fitbit/conftest.py b/tests/components/fitbit/conftest.py index 7499a0609330f..155e54995434c 100644 --- a/tests/components/fitbit/conftest.py +++ b/tests/components/fitbit/conftest.py @@ -10,15 +10,28 @@ import pytest from requests_mock.mocker import Mocker -from homeassistant.components.fitbit.const import DOMAIN +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.fitbit.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + DOMAIN, + OAUTH_SCOPES, +) +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry + CLIENT_ID = "1234" CLIENT_SECRET = "5678" PROFILE_USER_ID = "fitbit-api-user-id-1" -FAKE_TOKEN = "some-token" +FAKE_ACCESS_TOKEN = "some-access-token" FAKE_REFRESH_TOKEN = "some-refresh-token" +FAKE_AUTH_IMPL = "conftest-imported-cred" PROFILE_API_URL = "https://api.fitbit.com/1/user/-/profile.json" DEVICES_API_URL = "https://api.fitbit.com/1/user/-/devices.json" @@ -26,6 +39,14 @@ "https://api.fitbit.com/1/user/-/{resource}/date/today/7d.json" ) +# These constants differ from values in the config entry or fitbit.conf +SERVER_ACCESS_TOKEN = { + "refresh_token": "server-access-token", + "access_token": "server-refresh-token", + "type": "Bearer", + "expires_in": 60, +} + @pytest.fixture(name="token_expiration_time") def mcok_token_expiration_time() -> float: @@ -33,29 +54,73 @@ def mcok_token_expiration_time() -> float: return time.time() + 86400 +@pytest.fixture(name="scopes") +def mock_scopes() -> list[str]: + """Fixture for expiration time of the config entry auth token.""" + return OAUTH_SCOPES + + +@pytest.fixture(name="token_entry") +def mock_token_entry(token_expiration_time: float, scopes: list[str]) -> dict[str, Any]: + """Fixture for OAuth 'token' data for a ConfigEntry.""" + return { + "access_token": FAKE_ACCESS_TOKEN, + "refresh_token": FAKE_REFRESH_TOKEN, + "scope": " ".join(scopes), + "token_type": "Bearer", + "expires_at": token_expiration_time, + } + + +@pytest.fixture(name="config_entry") +def mock_config_entry(token_entry: dict[str, Any]) -> MockConfigEntry: + """Fixture for a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": FAKE_AUTH_IMPL, + "token": token_entry, + }, + unique_id=PROFILE_USER_ID, + ) + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + FAKE_AUTH_IMPL, + ) + + @pytest.fixture(name="fitbit_config_yaml") -def mock_fitbit_config_yaml(token_expiration_time: float) -> dict[str, Any]: +def mock_fitbit_config_yaml(token_expiration_time: float) -> dict[str, Any] | None: """Fixture for the yaml fitbit.conf file contents.""" return { - "access_token": FAKE_TOKEN, + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + "access_token": FAKE_ACCESS_TOKEN, "refresh_token": FAKE_REFRESH_TOKEN, "last_saved_at": token_expiration_time, } -@pytest.fixture(name="fitbit_config_setup", autouse=True) +@pytest.fixture(name="fitbit_config_setup") def mock_fitbit_config_setup( - fitbit_config_yaml: dict[str, Any], + fitbit_config_yaml: dict[str, Any] | None, ) -> Generator[None, None, None]: """Fixture to mock out fitbit.conf file data loading and persistence.""" - + has_config = fitbit_config_yaml is not None with patch( - "homeassistant.components.fitbit.sensor.os.path.isfile", return_value=True + "homeassistant.components.fitbit.sensor.os.path.isfile", + return_value=has_config, ), patch( "homeassistant.components.fitbit.sensor.load_json_object", return_value=fitbit_config_yaml, - ), patch( - "homeassistant.components.fitbit.sensor.save_json", ): yield @@ -112,6 +177,30 @@ async def run() -> bool: return run +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [] + + +@pytest.fixture(name="integration_setup") +async def mock_integration_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + platforms: list[str], +) -> Callable[[], Awaitable[bool]]: + """Fixture to set up the integration.""" + config_entry.add_to_hass(hass) + + async def run() -> bool: + with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return result + + return run + + @pytest.fixture(name="profile_id") def mock_profile_id() -> str: """Fixture for the profile id returned from the API response.""" diff --git a/tests/components/fitbit/test_config_flow.py b/tests/components/fitbit/test_config_flow.py new file mode 100644 index 0000000000000..df4bae89b47f7 --- /dev/null +++ b/tests/components/fitbit/test_config_flow.py @@ -0,0 +1,461 @@ +"""Test the fitbit config flow.""" + +from collections.abc import Awaitable, Callable +from http import HTTPStatus +from unittest.mock import patch + +import pytest +from requests_mock.mocker import Mocker + +from homeassistant import config_entries +from homeassistant.components.fitbit.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow, issue_registry as ir + +from .conftest import ( + CLIENT_ID, + FAKE_ACCESS_TOKEN, + FAKE_AUTH_IMPL, + FAKE_REFRESH_TOKEN, + PROFILE_API_URL, + PROFILE_USER_ID, + SERVER_ACCESS_TOKEN, +) + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +REDIRECT_URL = "https://example.com/auth/external/callback" + + +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + profile: None, + setup_credentials: None, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["type"] == FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json=SERVER_ACCESS_TOKEN, + ) + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(mock_setup.mock_calls) == 1 + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + config_entry = entries[0] + assert config_entry.title == "My name" + assert config_entry.unique_id == PROFILE_USER_ID + + data = dict(config_entry.data) + assert "token" in data + del data["token"]["expires_at"] + assert dict(config_entry.data) == { + "auth_implementation": FAKE_AUTH_IMPL, + "token": SERVER_ACCESS_TOKEN, + } + + +async def test_api_failure( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + requests_mock: Mocker, + setup_credentials: None, +) -> None: + """Test a failure to fetch the profile during the setup flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["type"] == FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json=SERVER_ACCESS_TOKEN, + ) + + requests_mock.register_uri( + "GET", PROFILE_API_URL, status_code=HTTPStatus.INTERNAL_SERVER_ERROR + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "cannot_connect" + + +async def test_config_entry_already_exists( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + requests_mock: Mocker, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, +) -> None: + """Test that an account may only be configured once.""" + + # Verify existing config entry + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["type"] == FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json=SERVER_ACCESS_TOKEN, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +async def test_import_fitbit_config( + hass: HomeAssistant, + fitbit_config_setup: None, + sensor_platform_setup: Callable[[], Awaitable[bool]], + issue_registry: ir.IssueRegistry, +) -> None: + """Test that platform configuration is imported successfully.""" + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + await sensor_platform_setup() + + assert len(mock_setup.mock_calls) == 1 + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + # Verify valid profile can be fetched from the API + config_entry = entries[0] + assert config_entry.title == "My name" + assert config_entry.unique_id == PROFILE_USER_ID + + data = dict(config_entry.data) + assert "token" in data + del data["token"]["expires_at"] + # Verify imported values from fitbit.conf and configuration.yaml + assert dict(config_entry.data) == { + "auth_implementation": DOMAIN, + "clock_format": "24H", + "monitored_resources": ["activities/steps"], + "token": { + "access_token": FAKE_ACCESS_TOKEN, + "refresh_token": FAKE_REFRESH_TOKEN, + }, + "unit_system": "default", + } + + # Verify an issue is raised for deprecated configuration.yaml + issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) + assert issue + assert issue.translation_key == "deprecated_yaml_import" + + +async def test_import_fitbit_config_failure_cannot_connect( + hass: HomeAssistant, + fitbit_config_setup: None, + sensor_platform_setup: Callable[[], Awaitable[bool]], + issue_registry: ir.IssueRegistry, + requests_mock: Mocker, +) -> None: + """Test platform configuration fails to import successfully.""" + + requests_mock.register_uri( + "GET", PROFILE_API_URL, status_code=HTTPStatus.INTERNAL_SERVER_ERROR + ) + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + await sensor_platform_setup() + + assert len(mock_setup.mock_calls) == 0 + + # Verify an issue is raised that we were unable to import configuration + issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) + assert issue + assert issue.translation_key == "deprecated_yaml_import_issue_cannot_connect" + + +async def test_import_fitbit_config_already_exists( + hass: HomeAssistant, + config_entry: MockConfigEntry, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + fitbit_config_setup: None, + sensor_platform_setup: Callable[[], Awaitable[bool]], + issue_registry: ir.IssueRegistry, +) -> None: + """Test that platform configuration is not imported if it already exists.""" + + # Verify existing config entry + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_config_entry_setup: + await integration_setup() + + assert len(mock_config_entry_setup.mock_calls) == 1 + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_import_setup: + await sensor_platform_setup() + + assert len(mock_import_setup.mock_calls) == 0 + + # Still one config entry + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + # Verify an issue is raised for deprecated configuration.yaml + issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) + assert issue + assert issue.translation_key == "deprecated_yaml_import" + + +async def test_platform_setup_without_import( + hass: HomeAssistant, + sensor_platform_setup: Callable[[], Awaitable[bool]], + issue_registry: ir.IssueRegistry, +) -> None: + """Test platform configuration.yaml but no existing fitbit.conf credentials.""" + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + await sensor_platform_setup() + + # Verify no configuration entry is imported since the integration is not + # fully setup properly + assert len(mock_setup.mock_calls) == 0 + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 0 + + # Verify an issue is raised for deprecated configuration.yaml + assert len(issue_registry.issues) == 1 + issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) + assert issue + assert issue.translation_key == "deprecated_yaml_no_import" + + +async def test_reauth_flow( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + profile: None, + setup_credentials: None, +) -> None: + """Test OAuth reauthentication flow will update existing config entry.""" + config_entry.add_to_hass(hass) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + # config_entry.req initiates reauth + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input={}, + ) + assert result["type"] == FlowResultType.EXTERNAL_STEP + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=none" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "updated-refresh-token", + "access_token": "updated-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "reauth_successful" + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + assert config_entry.data["token"]["refresh_token"] == "updated-refresh-token" + + +@pytest.mark.parametrize("profile_id", ["other-user-id"]) +async def test_reauth_wrong_user_id( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + profile: None, + setup_credentials: None, +) -> None: + """Test OAuth reauthentication where the wrong user is selected.""" + config_entry.add_to_hass(hass) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input={}, + ) + assert result["type"] == FlowResultType.EXTERNAL_STEP + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=none" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "updated-refresh-token", + "access_token": "updated-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "wrong_account" + + assert len(mock_setup.mock_calls) == 0 diff --git a/tests/components/fitbit/test_init.py b/tests/components/fitbit/test_init.py new file mode 100644 index 0000000000000..32dc9b0cc9815 --- /dev/null +++ b/tests/components/fitbit/test_init.py @@ -0,0 +1,127 @@ +"""Test fitbit component.""" + +from collections.abc import Awaitable, Callable +from http import HTTPStatus + +import pytest + +from homeassistant.components.fitbit.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + OAUTH2_TOKEN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import ( + CLIENT_ID, + CLIENT_SECRET, + FAKE_ACCESS_TOKEN, + FAKE_REFRESH_TOKEN, + SERVER_ACCESS_TOKEN, +) + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_setup( + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + setup_credentials: None, +) -> None: + """Test setting up the integration.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + + assert await integration_setup() + assert config_entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("token_expiration_time", "server_status"), + [ + (12345, HTTPStatus.INTERNAL_SERVER_ERROR), + (12345, HTTPStatus.FORBIDDEN), + (12345, HTTPStatus.NOT_FOUND), + ], +) +async def test_token_refresh_failure( + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + server_status: HTTPStatus, +) -> None: + """Test where token is expired and the refresh attempt fails and will be retried.""" + + aioclient_mock.post( + OAUTH2_TOKEN, + status=server_status, + ) + + assert not await integration_setup() + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize("token_expiration_time", [12345]) +async def test_token_refresh_success( + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, +) -> None: + """Test where token is expired and the refresh attempt succeeds.""" + + assert config_entry.data["token"]["access_token"] == FAKE_ACCESS_TOKEN + + aioclient_mock.post( + OAUTH2_TOKEN, + json=SERVER_ACCESS_TOKEN, + ) + + assert await integration_setup() + assert config_entry.state == ConfigEntryState.LOADED + + # Verify token request + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[0][2] == { + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + "grant_type": "refresh_token", + "refresh_token": FAKE_REFRESH_TOKEN, + } + + # Verify updated token + assert ( + config_entry.data["token"]["access_token"] + == SERVER_ACCESS_TOKEN["access_token"] + ) + + +@pytest.mark.parametrize("token_expiration_time", [12345]) +async def test_token_requires_reauth( + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, +) -> None: + """Test where token is expired and the refresh attempt requires reauth.""" + + aioclient_mock.post( + OAUTH2_TOKEN, + status=HTTPStatus.UNAUTHORIZED, + ) + + assert not await integration_setup() + assert config_entry.state == ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 636afeacf168e..9e2089b959c89 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -7,6 +7,8 @@ import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.fitbit.const import DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -32,6 +34,12 @@ } +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR] + + @pytest.mark.parametrize( ( "monitored_resources", @@ -176,6 +184,7 @@ ) async def test_sensors( hass: HomeAssistant, + fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], register_timeseries: Callable[[str, dict[str, Any]], None], entity_registry: er.EntityRegistry, @@ -190,6 +199,8 @@ async def test_sensors( api_resource, timeseries_response(api_resource.replace("/", "-"), api_value) ) await sensor_platform_setup() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 state = hass.states.get(entity_id) assert state @@ -204,12 +215,15 @@ async def test_sensors( ) async def test_device_battery_level( hass: HomeAssistant, + fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], entity_registry: er.EntityRegistry, ) -> None: """Test battery level sensor for devices.""" - await sensor_platform_setup() + assert await sensor_platform_setup() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 state = hass.states.get("sensor.charge_2_battery") assert state @@ -269,6 +283,7 @@ async def test_device_battery_level( ) async def test_profile_local( hass: HomeAssistant, + fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], register_timeseries: Callable[[str, dict[str, Any]], None], expected_unit: str, @@ -277,6 +292,8 @@ async def test_profile_local( register_timeseries("body/weight", timeseries_response("body-weight", "175")) await sensor_platform_setup() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 state = hass.states.get("sensor.weight") assert state @@ -315,6 +332,7 @@ async def test_profile_local( ) async def test_sleep_time_clock_format( hass: HomeAssistant, + fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], register_timeseries: Callable[[str, dict[str, Any]], None], api_response: str, @@ -330,3 +348,165 @@ async def test_sleep_time_clock_format( state = hass.states.get("sensor.sleep_start_time") assert state assert state.state == expected_state + + +@pytest.mark.parametrize( + ("scopes"), + [(["activity"])], +) +async def test_activity_scope_config_entry( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], + entity_registry: er.EntityRegistry, +) -> None: + """Test activity sensors are enabled.""" + + for api_resource in ( + "activities/activityCalories", + "activities/calories", + "activities/distance", + "activities/elevation", + "activities/floors", + "activities/minutesFairlyActive", + "activities/minutesLightlyActive", + "activities/minutesSedentary", + "activities/minutesVeryActive", + "activities/steps", + ): + register_timeseries( + api_resource, timeseries_response(api_resource.replace("/", "-"), "0") + ) + assert await integration_setup() + + states = hass.states.async_all() + assert {s.entity_id for s in states} == { + "sensor.activity_calories", + "sensor.calories", + "sensor.distance", + "sensor.elevation", + "sensor.floors", + "sensor.minutes_fairly_active", + "sensor.minutes_lightly_active", + "sensor.minutes_sedentary", + "sensor.minutes_very_active", + "sensor.steps", + } + + +@pytest.mark.parametrize( + ("scopes"), + [(["heartrate"])], +) +async def test_heartrate_scope_config_entry( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], + entity_registry: er.EntityRegistry, +) -> None: + """Test heartrate sensors are enabled.""" + + register_timeseries( + "activities/heart", + timeseries_response("activities-heart", {"restingHeartRate": "0"}), + ) + assert await integration_setup() + + states = hass.states.async_all() + assert {s.entity_id for s in states} == { + "sensor.resting_heart_rate", + } + + +@pytest.mark.parametrize( + ("scopes"), + [(["sleep"])], +) +async def test_sleep_scope_config_entry( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], + entity_registry: er.EntityRegistry, +) -> None: + """Test sleep sensors are enabled.""" + + for api_resource in ( + "sleep/startTime", + "sleep/timeInBed", + "sleep/minutesToFallAsleep", + "sleep/minutesAwake", + "sleep/minutesAsleep", + "sleep/minutesAfterWakeup", + "sleep/efficiency", + "sleep/awakeningsCount", + ): + register_timeseries( + api_resource, + timeseries_response(api_resource.replace("/", "-"), "0"), + ) + assert await integration_setup() + + states = hass.states.async_all() + assert {s.entity_id for s in states} == { + "sensor.awakenings_count", + "sensor.sleep_efficiency", + "sensor.minutes_after_wakeup", + "sensor.sleep_minutes_asleep", + "sensor.sleep_minutes_awake", + "sensor.sleep_minutes_to_fall_asleep", + "sensor.sleep_time_in_bed", + "sensor.sleep_start_time", + } + + +@pytest.mark.parametrize( + ("scopes"), + [(["weight"])], +) +async def test_weight_scope_config_entry( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], + entity_registry: er.EntityRegistry, +) -> None: + """Test sleep sensors are enabled.""" + + register_timeseries("body/weight", timeseries_response("body-weight", "0")) + assert await integration_setup() + + states = hass.states.async_all() + assert [s.entity_id for s in states] == [ + "sensor.weight", + ] + + +@pytest.mark.parametrize( + ("scopes", "devices_response"), + [(["settings"], [DEVICE_RESPONSE_CHARGE_2])], +) +async def test_settings_scope_config_entry( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], + entity_registry: er.EntityRegistry, +) -> None: + """Test heartrate sensors are enabled.""" + + for api_resource in ("activities/heart",): + register_timeseries( + api_resource, + timeseries_response( + api_resource.replace("/", "-"), {"restingHeartRate": "0"} + ), + ) + assert await integration_setup() + + states = hass.states.async_all() + assert [s.entity_id for s in states] == [ + "sensor.charge_2_battery", + ] diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index 17cd288050cfd..fbc77cdee9ee9 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -85,7 +85,7 @@ async def _async_zha_physical_discovery(self) -> dict[str, Any]: def _zha_name(self) -> str: """Return the ZHA name.""" - return "Test Multi-PAN" + return "Test Multiprotocol" def _hardware_name(self) -> str: """Return the name of the hardware.""" @@ -353,7 +353,7 @@ async def test_option_flow_install_multi_pan_addon_zha( }, "radio_type": "ezsp", } - assert zha_config_entry.title == "Test Multi-PAN" + assert zha_config_entry.title == "Test Multiprotocol" result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE @@ -663,7 +663,7 @@ async def test_option_flow_addon_installed_same_device_uninstall( }, domain=ZHA_DOMAIN, options={}, - title="Test Multi-PAN", + title="Test Multiprotocol", ) zha_config_entry.add_to_hass(hass) @@ -928,7 +928,7 @@ async def test_option_flow_flasher_install_failure( }, domain=ZHA_DOMAIN, options={}, - title="Test Multi-PAN", + title="Test Multiprotocol", ) zha_config_entry.add_to_hass(hass) @@ -1071,7 +1071,7 @@ async def test_option_flow_uninstall_migration_initiate_failure( }, domain=ZHA_DOMAIN, options={}, - title="Test Multi-PAN", + title="Test Multiprotocol", ) zha_config_entry.add_to_hass(hass) @@ -1132,7 +1132,7 @@ async def test_option_flow_uninstall_migration_finish_failure( }, domain=ZHA_DOMAIN, options={}, - title="Test Multi-PAN", + title="Test Multiprotocol", ) zha_config_entry.add_to_hass(hass) diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index 3afc8c24774fd..e00603dc8f784 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -207,7 +207,7 @@ async def test_setup_zha_multipan( "radio_type": "ezsp", } assert config_entry.options == {} - assert config_entry.title == "SkyConnect Multi-PAN" + assert config_entry.title == "SkyConnect Multiprotocol" async def test_setup_zha_multipan_other_device( diff --git a/tests/components/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py index a785e46c8b2d8..addc519c86533 100644 --- a/tests/components/homeassistant_yellow/test_init.py +++ b/tests/components/homeassistant_yellow/test_init.py @@ -152,7 +152,7 @@ async def test_setup_zha_multipan( "radio_type": "ezsp", } assert config_entry.options == {} - assert config_entry.title == "Yellow Multi-PAN" + assert config_entry.title == "Yellow Multiprotocol" async def test_setup_zha_multipan_other_device( diff --git a/tests/components/rainbird/conftest.py b/tests/components/rainbird/conftest.py index dbc3456117c91..f25bdfb1d86f4 100644 --- a/tests/components/rainbird/conftest.py +++ b/tests/components/rainbird/conftest.py @@ -86,7 +86,7 @@ def yaml_config() -> dict[str, Any]: @pytest.fixture -async def unique_id() -> str: +async def config_entry_unique_id() -> str: """Fixture for serial number used in the config entry.""" return SERIAL_NUMBER @@ -100,13 +100,13 @@ async def config_entry_data() -> dict[str, Any]: @pytest.fixture async def config_entry( config_entry_data: dict[str, Any] | None, - unique_id: str, + config_entry_unique_id: str | None, ) -> MockConfigEntry | None: """Fixture for MockConfigEntry.""" if config_entry_data is None: return None return MockConfigEntry( - unique_id=unique_id, + unique_id=config_entry_unique_id, domain=DOMAIN, data=config_entry_data, options={ATTR_DURATION: DEFAULT_TRIGGER_TIME_MINUTES}, diff --git a/tests/components/rainbird/test_binary_sensor.py b/tests/components/rainbird/test_binary_sensor.py index cfa2c4d268481..e372a10ae2300 100644 --- a/tests/components/rainbird/test_binary_sensor.py +++ b/tests/components/rainbird/test_binary_sensor.py @@ -5,6 +5,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .conftest import RAIN_SENSOR_OFF, RAIN_SENSOR_ON, ComponentSetup @@ -25,6 +26,7 @@ async def test_rainsensor( hass: HomeAssistant, setup_integration: ComponentSetup, responses: list[AiohttpClientMockResponse], + entity_registry: er.EntityRegistry, expected_state: bool, ) -> None: """Test rainsensor binary sensor.""" @@ -38,3 +40,37 @@ async def test_rainsensor( "friendly_name": "Rain Bird Controller Rainsensor", "icon": "mdi:water", } + + entity_entry = entity_registry.async_get( + "binary_sensor.rain_bird_controller_rainsensor" + ) + assert entity_entry + assert entity_entry.unique_id == "1263613994342-rainsensor" + + +@pytest.mark.parametrize( + ("config_entry_unique_id"), + [ + (None), + ], +) +async def test_no_unique_id( + hass: HomeAssistant, + setup_integration: ComponentSetup, + responses: list[AiohttpClientMockResponse], + entity_registry: er.EntityRegistry, +) -> None: + """Test rainsensor binary sensor with no unique id.""" + + assert await setup_integration() + + rainsensor = hass.states.get("binary_sensor.rain_bird_controller_rainsensor") + assert rainsensor is not None + assert ( + rainsensor.attributes.get("friendly_name") == "Rain Bird Controller Rainsensor" + ) + + entity_entry = entity_registry.async_get( + "binary_sensor.rain_bird_controller_rainsensor" + ) + assert not entity_entry diff --git a/tests/components/rainbird/test_calendar.py b/tests/components/rainbird/test_calendar.py index 2028fccc24f7e..2e486226a7be0 100644 --- a/tests/components/rainbird/test_calendar.py +++ b/tests/components/rainbird/test_calendar.py @@ -14,6 +14,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .conftest import ComponentSetup, mock_response, mock_response_error @@ -176,6 +177,7 @@ async def test_event_state( freezer: FrozenDateTimeFactory, freeze_time: datetime.datetime, expected_state: str, + entity_registry: er.EntityRegistry, ) -> None: """Test calendar upcoming event state.""" freezer.move_to(freeze_time) @@ -196,6 +198,10 @@ async def test_event_state( } assert state.state == expected_state + entity = entity_registry.async_get(TEST_ENTITY) + assert entity + assert entity.unique_id == 1263613994342 + @pytest.mark.parametrize( ("model_and_version_response", "has_entity"), @@ -270,3 +276,27 @@ async def test_program_schedule_disabled( "friendly_name": "Rain Bird Controller", "icon": "mdi:sprinkler", } + + +@pytest.mark.parametrize( + ("config_entry_unique_id"), + [ + (None), + ], +) +async def test_no_unique_id( + hass: HomeAssistant, + setup_integration: ComponentSetup, + get_events: GetEventsFn, + entity_registry: er.EntityRegistry, +) -> None: + """Test calendar entity with no unique id.""" + + assert await setup_integration() + + state = hass.states.get(TEST_ENTITY) + assert state is not None + assert state.attributes.get("friendly_name") == "Rain Bird Controller" + + entity_entry = entity_registry.async_get(TEST_ENTITY) + assert not entity_entry diff --git a/tests/components/rainbird/test_config_flow.py b/tests/components/rainbird/test_config_flow.py index e7337ad6508d4..cfc4ff3b5cb3e 100644 --- a/tests/components/rainbird/test_config_flow.py +++ b/tests/components/rainbird/test_config_flow.py @@ -106,7 +106,7 @@ async def test_controller_flow( @pytest.mark.parametrize( ( - "unique_id", + "config_entry_unique_id", "config_entry_data", "config_flow_responses", "expected_config_entry", @@ -154,7 +154,7 @@ async def test_multiple_config_entries( @pytest.mark.parametrize( ( - "unique_id", + "config_entry_unique_id", "config_entry_data", "config_flow_responses", ), diff --git a/tests/components/rainbird/test_number.py b/tests/components/rainbird/test_number.py index 6ce7d10c9f292..5d208f08a250f 100644 --- a/tests/components/rainbird/test_number.py +++ b/tests/components/rainbird/test_number.py @@ -10,7 +10,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import ( ACK_ECHO, @@ -39,8 +39,9 @@ async def test_number_values( hass: HomeAssistant, setup_integration: ComponentSetup, expected_state: str, + entity_registry: er.EntityRegistry, ) -> None: - """Test sensor platform.""" + """Test number platform.""" assert await setup_integration() @@ -57,6 +58,10 @@ async def test_number_values( "unit_of_measurement": "d", } + entity_entry = entity_registry.async_get("number.rain_bird_controller_rain_delay") + assert entity_entry + assert entity_entry.unique_id == "1263613994342-rain-delay" + async def test_set_value( hass: HomeAssistant, @@ -127,3 +132,28 @@ async def test_set_value_error( ) assert len(aioclient_mock.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("config_entry_unique_id"), + [ + (None), + ], +) +async def test_no_unique_id( + hass: HomeAssistant, + setup_integration: ComponentSetup, + entity_registry: er.EntityRegistry, +) -> None: + """Test number platform with no unique id.""" + + assert await setup_integration() + + raindelay = hass.states.get("number.rain_bird_controller_rain_delay") + assert raindelay is not None + assert ( + raindelay.attributes.get("friendly_name") == "Rain Bird Controller Rain delay" + ) + + entity_entry = entity_registry.async_get("number.rain_bird_controller_rain_delay") + assert not entity_entry diff --git a/tests/components/rainbird/test_sensor.py b/tests/components/rainbird/test_sensor.py index 049a5f15c4566..d8fb053c0ffde 100644 --- a/tests/components/rainbird/test_sensor.py +++ b/tests/components/rainbird/test_sensor.py @@ -5,8 +5,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .conftest import RAIN_DELAY, RAIN_DELAY_OFF, ComponentSetup +from .conftest import CONFIG_ENTRY_DATA, RAIN_DELAY, RAIN_DELAY_OFF, ComponentSetup @pytest.fixture @@ -22,6 +23,7 @@ def platforms() -> list[str]: async def test_sensors( hass: HomeAssistant, setup_integration: ComponentSetup, + entity_registry: er.EntityRegistry, expected_state: str, ) -> None: """Test sensor platform.""" @@ -35,3 +37,46 @@ async def test_sensors( "friendly_name": "Rain Bird Controller Raindelay", "icon": "mdi:water-off", } + + entity_entry = entity_registry.async_get("sensor.rain_bird_controller_raindelay") + assert entity_entry + assert entity_entry.unique_id == "1263613994342-raindelay" + + +@pytest.mark.parametrize( + ("config_entry_unique_id", "config_entry_data"), + [ + # Config entry setup without a unique id since it had no serial number + ( + None, + { + **CONFIG_ENTRY_DATA, + "serial_number": 0, + }, + ), + # Legacy case for old config entries with serial number 0 preserves old behavior + ( + "0", + { + **CONFIG_ENTRY_DATA, + "serial_number": 0, + }, + ), + ], +) +async def test_sensor_no_unique_id( + hass: HomeAssistant, + setup_integration: ComponentSetup, + entity_registry: er.EntityRegistry, + config_entry_unique_id: str | None, +) -> None: + """Test sensor platform with no unique id.""" + + assert await setup_integration() + + raindelay = hass.states.get("sensor.rain_bird_controller_raindelay") + assert raindelay is not None + assert raindelay.attributes.get("friendly_name") == "Rain Bird Controller Raindelay" + + entity_entry = entity_registry.async_get("sensor.rain_bird_controller_raindelay") + assert (entity_entry is None) == (config_entry_unique_id is None) diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index 9ce5e799c92fd..46a875e892889 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -8,6 +8,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from .conftest import ( ACK_ECHO, @@ -57,6 +58,7 @@ async def test_no_zones( async def test_zones( hass: HomeAssistant, setup_integration: ComponentSetup, + entity_registry: er.EntityRegistry, ) -> None: """Test switch platform with fake data that creates 7 zones with one enabled.""" @@ -100,6 +102,10 @@ async def test_zones( assert not hass.states.get("switch.rain_bird_sprinkler_8") + # Verify unique id for one of the switches + entity_entry = entity_registry.async_get("switch.rain_bird_sprinkler_3") + assert entity_entry.unique_id == "1263613994342-3" + async def test_switch_on( hass: HomeAssistant, @@ -275,3 +281,29 @@ async def test_switch_error( with pytest.raises(HomeAssistantError, match=expected_msg): await switch_common.async_turn_off(hass, "switch.rain_bird_sprinkler_3") await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("config_entry_unique_id"), + [ + None, + ], +) +async def test_no_unique_id( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + responses: list[AiohttpClientMockResponse], + entity_registry: er.EntityRegistry, +) -> None: + """Test an irrigation switch with no unique id.""" + + assert await setup_integration() + + zone = hass.states.get("switch.rain_bird_sprinkler_3") + assert zone is not None + assert zone.attributes.get("friendly_name") == "Rain Bird Sprinkler 3" + assert zone.state == "off" + + entity_entry = entity_registry.async_get("switch.rain_bird_sprinkler_3") + assert entity_entry is None diff --git a/tests/components/stream/common.py b/tests/components/stream/common.py index 7ea583c0ec373..ae4a4fc2d9d72 100644 --- a/tests/components/stream/common.py +++ b/tests/components/stream/common.py @@ -24,7 +24,7 @@ init=None, stream_id=0, start_time=FAKE_TIME, - stream_outputs=[], + _stream_outputs=[], ) AUDIO_SAMPLE_RATE = 8000 diff --git a/tests/components/waqi/test_sensor.py b/tests/components/waqi/test_sensor.py index 7feb37a1b09c8..46bd577c48f93 100644 --- a/tests/components/waqi/test_sensor.py +++ b/tests/components/waqi/test_sensor.py @@ -72,7 +72,7 @@ async def test_legacy_migration_already_imported( assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - state = hass.states.get("sensor.waqi_de_jongweg_utrecht") + state = hass.states.get("sensor.de_jongweg_utrecht") assert state.state == "29" hass.async_create_task( @@ -116,7 +116,7 @@ async def test_sensor_id_migration( ) assert len(entities) == 1 assert hass.states.get("sensor.waqi_4584") - assert hass.states.get("sensor.waqi_de_jongweg_utrecht") is None + assert hass.states.get("sensor.de_jongweg_utrecht") is None assert entities[0].unique_id == "4584_air_quality" @@ -132,7 +132,7 @@ async def test_sensor(hass: HomeAssistant, mock_config_entry: MockConfigEntry) - assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - state = hass.states.get("sensor.waqi_de_jongweg_utrecht") + state = hass.states.get("sensor.de_jongweg_utrecht") assert state.state == "29" diff --git a/tests/components/weatherkit/fixtures/weather_response.json b/tests/components/weatherkit/fixtures/weather_response.json index c2d619d85d8c9..7a38347bdf5e7 100644 --- a/tests/components/weatherkit/fixtures/weather_response.json +++ b/tests/components/weatherkit/fixtures/weather_response.json @@ -19,7 +19,7 @@ "conditionCode": "PartlyCloudy", "daylight": true, "humidity": 0.91, - "precipitationIntensity": 0.0, + "precipitationIntensity": 0.7, "pressure": 1009.8, "pressureTrend": "rising", "temperature": 22.9, diff --git a/tests/components/weatherkit/test_sensor.py b/tests/components/weatherkit/test_sensor.py new file mode 100644 index 0000000000000..6c6999c6bfd90 --- /dev/null +++ b/tests/components/weatherkit/test_sensor.py @@ -0,0 +1,27 @@ +"""Sensor entity tests for the WeatherKit integration.""" + +from typing import Any + +import pytest + +from homeassistant.core import HomeAssistant + +from . import init_integration + + +@pytest.mark.parametrize( + ("entity_name", "expected_value"), + [ + ("sensor.home_precipitation_intensity", 0.7), + ("sensor.home_pressure_trend", "rising"), + ], +) +async def test_sensor_values( + hass: HomeAssistant, entity_name: str, expected_value: Any +) -> None: + """Test that various sensor values match what we expect.""" + await init_integration(hass) + + state = hass.states.get(entity_name) + assert state + assert state.state == str(expected_value) diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 80fc1bf224140..a9ddd89a0b36f 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -563,6 +563,9 @@ def test_string_with_no_html() -> None: with pytest.raises(vol.Invalid): schema("Bold") + with pytest.raises(vol.Invalid): + schema("HTML element names are case-insensitive.") + for value in ( True, 3, diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index f62addb9a64af..4bf03b4d39bbf 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -1684,3 +1684,69 @@ async def test_restore_entity(hass, update_events, freezer): assert update_events[11] == {"action": "remove", "entity_id": "light.hue_1234"} # Restore entities the 3rd time assert update_events[12] == {"action": "create", "entity_id": "light.hue_1234"} + + +async def test_async_migrate_entry_delete_self(hass): + """Test async_migrate_entry.""" + registry = er.async_get(hass) + config_entry1 = MockConfigEntry(domain="test1") + config_entry2 = MockConfigEntry(domain="test2") + entry1 = registry.async_get_or_create( + "light", "hue", "1234", config_entry=config_entry1, original_name="Entry 1" + ) + entry2 = registry.async_get_or_create( + "light", "hue", "5678", config_entry=config_entry1, original_name="Entry 2" + ) + entry3 = registry.async_get_or_create( + "light", "hue", "90AB", config_entry=config_entry2, original_name="Entry 3" + ) + + @callback + def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, Any] | None: + entries.add(entity_entry.entity_id) + if entity_entry == entry1: + registry.async_remove(entry1.entity_id) + return None + if entity_entry == entry2: + return {"original_name": "Entry 2 renamed"} + return None + + entries = set() + await er.async_migrate_entries(hass, config_entry1.entry_id, _async_migrator) + assert entries == {entry1.entity_id, entry2.entity_id} + assert not registry.async_is_registered(entry1.entity_id) + entry2 = registry.async_get(entry2.entity_id) + assert entry2.original_name == "Entry 2 renamed" + assert registry.async_get(entry3.entity_id) is entry3 + + +async def test_async_migrate_entry_delete_other(hass): + """Test async_migrate_entry.""" + registry = er.async_get(hass) + config_entry1 = MockConfigEntry(domain="test1") + config_entry2 = MockConfigEntry(domain="test2") + entry1 = registry.async_get_or_create( + "light", "hue", "1234", config_entry=config_entry1, original_name="Entry 1" + ) + entry2 = registry.async_get_or_create( + "light", "hue", "5678", config_entry=config_entry1, original_name="Entry 2" + ) + registry.async_get_or_create( + "light", "hue", "90AB", config_entry=config_entry2, original_name="Entry 3" + ) + + @callback + def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, Any] | None: + entries.add(entity_entry.entity_id) + if entity_entry == entry1: + registry.async_remove(entry2.entity_id) + return None + if entity_entry == entry2: + # We should not get here + pytest.fail() + return None + + entries = set() + await er.async_migrate_entries(hass, config_entry1.entry_id, _async_migrator) + assert entries == {entry1.entity_id} + assert not registry.async_is_registered(entry2.entity_id) diff --git a/tests/util/test_distance.py b/tests/util/test_distance.py deleted file mode 100644 index c6a9d59cb734c..0000000000000 --- a/tests/util/test_distance.py +++ /dev/null @@ -1,211 +0,0 @@ -"""Test Home Assistant distance utility functions.""" - -import pytest - -from homeassistant.const import UnitOfLength -from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.distance as distance_util - -INVALID_SYMBOL = "bob" -VALID_SYMBOL = UnitOfLength.KILOMETERS - - -def test_raise_deprecation_warning(caplog: pytest.LogCaptureFixture) -> None: - """Ensure that a warning is raised on use of convert.""" - assert distance_util.convert(2, UnitOfLength.METERS, UnitOfLength.METERS) == 2 - assert "use unit_conversion.DistanceConverter instead" in caplog.text - - -def test_convert_same_unit() -> None: - """Test conversion from any unit to same unit.""" - assert ( - distance_util.convert(5, UnitOfLength.KILOMETERS, UnitOfLength.KILOMETERS) == 5 - ) - assert distance_util.convert(2, UnitOfLength.METERS, UnitOfLength.METERS) == 2 - assert ( - distance_util.convert(6, UnitOfLength.CENTIMETERS, UnitOfLength.CENTIMETERS) - == 6 - ) - assert ( - distance_util.convert(3, UnitOfLength.MILLIMETERS, UnitOfLength.MILLIMETERS) - == 3 - ) - assert distance_util.convert(10, UnitOfLength.MILES, UnitOfLength.MILES) == 10 - assert distance_util.convert(9, UnitOfLength.YARDS, UnitOfLength.YARDS) == 9 - assert distance_util.convert(8, UnitOfLength.FEET, UnitOfLength.FEET) == 8 - assert distance_util.convert(7, UnitOfLength.INCHES, UnitOfLength.INCHES) == 7 - - -def test_convert_invalid_unit() -> None: - """Test exception is thrown for invalid units.""" - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - distance_util.convert(5, INVALID_SYMBOL, VALID_SYMBOL) - - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - distance_util.convert(5, VALID_SYMBOL, INVALID_SYMBOL) - - -def test_convert_nonnumeric_value() -> None: - """Test exception is thrown for nonnumeric type.""" - with pytest.raises(TypeError): - distance_util.convert("a", UnitOfLength.KILOMETERS, UnitOfLength.METERS) - - -@pytest.mark.parametrize( - ("unit", "expected"), - [ - (UnitOfLength.KILOMETERS, 8.04672), - (UnitOfLength.METERS, 8046.72), - (UnitOfLength.CENTIMETERS, 804672.0), - (UnitOfLength.MILLIMETERS, 8046720.0), - (UnitOfLength.YARDS, 8800.0), - (UnitOfLength.FEET, 26400.0008448), - (UnitOfLength.INCHES, 316800.171072), - ], -) -def test_convert_from_miles(unit, expected) -> None: - """Test conversion from miles to other units.""" - miles = 5 - assert distance_util.convert(miles, UnitOfLength.MILES, unit) == pytest.approx( - expected - ) - - -@pytest.mark.parametrize( - ("unit", "expected"), - [ - (UnitOfLength.KILOMETERS, 0.0045720000000000005), - (UnitOfLength.METERS, 4.572), - (UnitOfLength.CENTIMETERS, 457.2), - (UnitOfLength.MILLIMETERS, 4572), - (UnitOfLength.MILES, 0.002840908212), - (UnitOfLength.FEET, 15.00000048), - (UnitOfLength.INCHES, 180.0000972), - ], -) -def test_convert_from_yards(unit, expected) -> None: - """Test conversion from yards to other units.""" - yards = 5 - assert distance_util.convert(yards, UnitOfLength.YARDS, unit) == pytest.approx( - expected - ) - - -@pytest.mark.parametrize( - ("unit", "expected"), - [ - (UnitOfLength.KILOMETERS, 1.524), - (UnitOfLength.METERS, 1524), - (UnitOfLength.CENTIMETERS, 152400.0), - (UnitOfLength.MILLIMETERS, 1524000.0), - (UnitOfLength.MILES, 0.9469694040000001), - (UnitOfLength.YARDS, 1666.66667), - (UnitOfLength.INCHES, 60000.032400000004), - ], -) -def test_convert_from_feet(unit, expected) -> None: - """Test conversion from feet to other units.""" - feet = 5000 - assert distance_util.convert(feet, UnitOfLength.FEET, unit) == pytest.approx( - expected - ) - - -@pytest.mark.parametrize( - ("unit", "expected"), - [ - (UnitOfLength.KILOMETERS, 0.127), - (UnitOfLength.METERS, 127.0), - (UnitOfLength.CENTIMETERS, 12700.0), - (UnitOfLength.MILLIMETERS, 127000.0), - (UnitOfLength.MILES, 0.078914117), - (UnitOfLength.YARDS, 138.88889), - (UnitOfLength.FEET, 416.66668), - ], -) -def test_convert_from_inches(unit, expected) -> None: - """Test conversion from inches to other units.""" - inches = 5000 - assert distance_util.convert(inches, UnitOfLength.INCHES, unit) == pytest.approx( - expected - ) - - -@pytest.mark.parametrize( - ("unit", "expected"), - [ - (UnitOfLength.METERS, 5000), - (UnitOfLength.CENTIMETERS, 500000), - (UnitOfLength.MILLIMETERS, 5000000), - (UnitOfLength.MILES, 3.106855), - (UnitOfLength.YARDS, 5468.066), - (UnitOfLength.FEET, 16404.2), - (UnitOfLength.INCHES, 196850.5), - ], -) -def test_convert_from_kilometers(unit, expected) -> None: - """Test conversion from kilometers to other units.""" - km = 5 - assert distance_util.convert(km, UnitOfLength.KILOMETERS, unit) == pytest.approx( - expected - ) - - -@pytest.mark.parametrize( - ("unit", "expected"), - [ - (UnitOfLength.KILOMETERS, 5), - (UnitOfLength.CENTIMETERS, 500000), - (UnitOfLength.MILLIMETERS, 5000000), - (UnitOfLength.MILES, 3.106855), - (UnitOfLength.YARDS, 5468.066), - (UnitOfLength.FEET, 16404.2), - (UnitOfLength.INCHES, 196850.5), - ], -) -def test_convert_from_meters(unit, expected) -> None: - """Test conversion from meters to other units.""" - m = 5000 - assert distance_util.convert(m, UnitOfLength.METERS, unit) == pytest.approx( - expected - ) - - -@pytest.mark.parametrize( - ("unit", "expected"), - [ - (UnitOfLength.KILOMETERS, 5), - (UnitOfLength.METERS, 5000), - (UnitOfLength.MILLIMETERS, 5000000), - (UnitOfLength.MILES, 3.106855), - (UnitOfLength.YARDS, 5468.066), - (UnitOfLength.FEET, 16404.2), - (UnitOfLength.INCHES, 196850.5), - ], -) -def test_convert_from_centimeters(unit, expected) -> None: - """Test conversion from centimeters to other units.""" - cm = 500000 - assert distance_util.convert(cm, UnitOfLength.CENTIMETERS, unit) == pytest.approx( - expected - ) - - -@pytest.mark.parametrize( - ("unit", "expected"), - [ - (UnitOfLength.KILOMETERS, 5), - (UnitOfLength.METERS, 5000), - (UnitOfLength.CENTIMETERS, 500000), - (UnitOfLength.MILES, 3.106855), - (UnitOfLength.YARDS, 5468.066), - (UnitOfLength.FEET, 16404.2), - (UnitOfLength.INCHES, 196850.5), - ], -) -def test_convert_from_millimeters(unit, expected) -> None: - """Test conversion from millimeters to other units.""" - mm = 5000000 - assert distance_util.convert(mm, UnitOfLength.MILLIMETERS, unit) == pytest.approx( - expected - ) diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 18f0c9a12c17c..e7affecfaf4d0 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -105,7 +105,7 @@ VolumeConverter: (UnitOfVolume.GALLONS, UnitOfVolume.LITERS, 0.264172), } -# Dict containing a conversion test for every know unit. +# Dict containing a conversion test for every known unit. _CONVERTED_VALUE: dict[ type[BaseUnitConverter], list[tuple[float, str | None, float, str | None]] ] = { diff --git a/tests/util/test_volume.py b/tests/util/test_volume.py deleted file mode 100644 index f8a73929b7061..0000000000000 --- a/tests/util/test_volume.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Test Home Assistant volume utility functions.""" - -import pytest - -from homeassistant.const import ( - VOLUME_CUBIC_FEET, - VOLUME_CUBIC_METERS, - VOLUME_FLUID_OUNCE, - VOLUME_GALLONS, - VOLUME_LITERS, - VOLUME_MILLILITERS, -) -from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.volume as volume_util - -INVALID_SYMBOL = "bob" -VALID_SYMBOL = VOLUME_LITERS - - -def test_raise_deprecation_warning(caplog: pytest.LogCaptureFixture) -> None: - """Ensure that a warning is raised on use of convert.""" - assert volume_util.convert(2, VOLUME_LITERS, VOLUME_LITERS) == 2 - assert "use unit_conversion.VolumeConverter instead" in caplog.text - - -@pytest.mark.parametrize( - ("function_name", "value", "expected"), - [ - ("liter_to_gallon", 2, pytest.approx(0.528344)), - ("gallon_to_liter", 2, 7.570823568), - ("cubic_meter_to_cubic_feet", 2, pytest.approx(70.629333)), - ("cubic_feet_to_cubic_meter", 2, pytest.approx(0.0566337)), - ], -) -def test_deprecated_functions( - function_name: str, value: float, expected: float -) -> None: - """Test that deprecated function still work.""" - convert = getattr(volume_util, function_name) - assert convert(value) == expected - - -def test_convert_same_unit() -> None: - """Test conversion from any unit to same unit.""" - assert volume_util.convert(2, VOLUME_LITERS, VOLUME_LITERS) == 2 - assert volume_util.convert(3, VOLUME_MILLILITERS, VOLUME_MILLILITERS) == 3 - assert volume_util.convert(4, VOLUME_GALLONS, VOLUME_GALLONS) == 4 - assert volume_util.convert(5, VOLUME_FLUID_OUNCE, VOLUME_FLUID_OUNCE) == 5 - - -def test_convert_invalid_unit() -> None: - """Test exception is thrown for invalid units.""" - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - volume_util.convert(5, INVALID_SYMBOL, VALID_SYMBOL) - - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - volume_util.convert(5, VALID_SYMBOL, INVALID_SYMBOL) - - -def test_convert_nonnumeric_value() -> None: - """Test exception is thrown for nonnumeric type.""" - with pytest.raises(TypeError): - volume_util.convert("a", VOLUME_GALLONS, VOLUME_LITERS) - - -def test_convert_from_liters() -> None: - """Test conversion from liters to other units.""" - liters = 5 - assert volume_util.convert(liters, VOLUME_LITERS, VOLUME_GALLONS) == pytest.approx( - 1.32086 - ) - - -def test_convert_from_gallons() -> None: - """Test conversion from gallons to other units.""" - gallons = 5 - assert volume_util.convert(gallons, VOLUME_GALLONS, VOLUME_LITERS) == pytest.approx( - 18.92706 - ) - - -def test_convert_from_cubic_meters() -> None: - """Test conversion from cubic meter to other units.""" - cubic_meters = 5 - assert volume_util.convert( - cubic_meters, VOLUME_CUBIC_METERS, VOLUME_CUBIC_FEET - ) == pytest.approx(176.5733335) - - -def test_convert_from_cubic_feet() -> None: - """Test conversion from cubic feet to cubic meters to other units.""" - cubic_feets = 500 - assert volume_util.convert( - cubic_feets, VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS - ) == pytest.approx(14.1584233) - - -@pytest.mark.parametrize( - ("source_unit", "target_unit", "expected"), - [ - (VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS, 14.1584233), - (VOLUME_CUBIC_FEET, VOLUME_FLUID_OUNCE, 478753.2467), - (VOLUME_CUBIC_FEET, VOLUME_GALLONS, 3740.25974), - (VOLUME_CUBIC_FEET, VOLUME_LITERS, 14158.42329599), - (VOLUME_CUBIC_FEET, VOLUME_MILLILITERS, 14158423.29599), - (VOLUME_CUBIC_METERS, VOLUME_CUBIC_METERS, 500), - (VOLUME_CUBIC_METERS, VOLUME_FLUID_OUNCE, 16907011.35), - (VOLUME_CUBIC_METERS, VOLUME_GALLONS, 132086.02617), - (VOLUME_CUBIC_METERS, VOLUME_LITERS, 500000), - (VOLUME_CUBIC_METERS, VOLUME_MILLILITERS, 500000000), - (VOLUME_FLUID_OUNCE, VOLUME_CUBIC_FEET, 0.52218967), - (VOLUME_FLUID_OUNCE, VOLUME_CUBIC_METERS, 0.014786764), - (VOLUME_FLUID_OUNCE, VOLUME_GALLONS, 3.90625), - (VOLUME_FLUID_OUNCE, VOLUME_LITERS, 14.786764), - (VOLUME_FLUID_OUNCE, VOLUME_MILLILITERS, 14786.764), - (VOLUME_GALLONS, VOLUME_CUBIC_FEET, 66.84027), - (VOLUME_GALLONS, VOLUME_CUBIC_METERS, 1.892706), - (VOLUME_GALLONS, VOLUME_FLUID_OUNCE, 64000), - (VOLUME_GALLONS, VOLUME_LITERS, 1892.70589), - (VOLUME_GALLONS, VOLUME_MILLILITERS, 1892705.89), - (VOLUME_LITERS, VOLUME_CUBIC_FEET, 17.65733), - (VOLUME_LITERS, VOLUME_CUBIC_METERS, 0.5), - (VOLUME_LITERS, VOLUME_FLUID_OUNCE, 16907.011), - (VOLUME_LITERS, VOLUME_GALLONS, 132.086), - (VOLUME_LITERS, VOLUME_MILLILITERS, 500000), - (VOLUME_MILLILITERS, VOLUME_CUBIC_FEET, 0.01765733), - (VOLUME_MILLILITERS, VOLUME_CUBIC_METERS, 0.0005), - (VOLUME_MILLILITERS, VOLUME_FLUID_OUNCE, 16.907), - (VOLUME_MILLILITERS, VOLUME_GALLONS, 0.132086), - (VOLUME_MILLILITERS, VOLUME_LITERS, 0.5), - ], -) -def test_convert(source_unit, target_unit, expected) -> None: - """Test conversion between units.""" - value = 500 - assert volume_util.convert(value, source_unit, target_unit) == pytest.approx( - expected - )