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
- )