diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index aaefd47a5536e3..3ec09f68a52c09 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -428,7 +428,7 @@ jobs: timeout-minutes: 60 strategy: matrix: - python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} + python-version: &matrix-python ${{ fromJson(needs.info.outputs.python_versions) }} steps: - *checkout - &setup-python-matrix @@ -514,9 +514,7 @@ jobs: if: steps.cache-apt-check.outputs.cache-hit != 'true' uses: &actions-cache-save actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: - path: | - ${{ env.APT_CACHE_DIR }} - ${{ env.APT_LIST_CACHE_DIR }} + path: *path-apt-cache key: *key-apt-cache - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' @@ -641,7 +639,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ${{ fromJson(needs.info.outputs.python_versions) }} + python-version: *matrix-python steps: - *checkout - *setup-python-matrix @@ -838,8 +836,8 @@ jobs: strategy: fail-fast: false matrix: - python-version: ${{ fromJson(needs.info.outputs.python_versions) }} - group: ${{ fromJson(needs.info.outputs.test_groups) }} + python-version: *matrix-python + group: &matrix-group ${{ fromJson(needs.info.outputs.test_groups) }} steps: - *cache-restore-apt - name: Install additional OS dependencies @@ -964,7 +962,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ${{ fromJson(needs.info.outputs.python_versions) }} + python-version: *matrix-python mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }} steps: - *cache-restore-apt @@ -1081,7 +1079,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ${{ fromJson(needs.info.outputs.python_versions) }} + python-version: *matrix-python postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }} steps: - *cache-restore-apt @@ -1218,8 +1216,8 @@ jobs: strategy: fail-fast: false matrix: - python-version: ${{ fromJson(needs.info.outputs.python_versions) }} - group: ${{ fromJson(needs.info.outputs.test_groups) }} + python-version: *matrix-python + group: *matrix-group steps: - *cache-restore-apt - name: Install additional OS dependencies diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index b6a4d0832f7d8b..251d586c119148 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -31,7 +31,8 @@ jobs: outputs: architectures: ${{ steps.info.outputs.architectures }} steps: - - name: Checkout the repository + - &checkout + name: Checkout the repository uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} @@ -91,7 +92,7 @@ jobs: ) > build_constraints.txt - name: Upload env_file - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: &actions-upload-artifact actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: env_file path: ./.env_file @@ -99,14 +100,14 @@ jobs: overwrite: true - name: Upload build_constraints - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: *actions-upload-artifact with: name: build_constraints path: ./build_constraints.txt overwrite: true - name: Upload requirements_diff - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: *actions-upload-artifact with: name: requirements_diff path: ./requirements_diff.txt @@ -118,7 +119,7 @@ jobs: python -m script.gen_requirements_all ci - name: Upload requirements_all_wheels - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: *actions-upload-artifact with: name: requirements_all_wheels path: ./requirements_all_wheels_*.txt @@ -130,25 +131,27 @@ jobs: runs-on: ubuntu-latest strategy: fail-fast: false - matrix: + matrix: &matrix-build abi: ["cp313"] arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - - name: Checkout the repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - *checkout - - name: Download env_file - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + - &download-env-file + name: Download env_file + uses: &actions-download-artifact actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: env_file - - name: Download build_constraints - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + - &download-build-constraints + name: Download build_constraints + uses: *actions-download-artifact with: name: build_constraints - - name: Download requirements_diff - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + - &download-requirements-diff + name: Download requirements_diff + uses: *actions-download-artifact with: name: requirements_diff @@ -160,7 +163,7 @@ jobs: # home-assistant/wheels doesn't support sha pinning - name: Build wheels - uses: home-assistant/wheels@2025.09.1 + uses: &home-assistant-wheels home-assistant/wheels@2025.09.1 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -180,30 +183,16 @@ jobs: runs-on: ubuntu-latest strategy: fail-fast: false - matrix: - abi: ["cp313"] - arch: ${{ fromJson(needs.init.outputs.architectures) }} + matrix: *matrix-build steps: - - name: Checkout the repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - - name: Download env_file - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 - with: - name: env_file - - - name: Download build_constraints - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 - with: - name: build_constraints + - *checkout - - name: Download requirements_diff - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 - with: - name: requirements_diff + - *download-env-file + - *download-build-constraints + - *download-requirements-diff - name: Download requirements_all_wheels - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: *actions-download-artifact with: name: requirements_all_wheels @@ -221,7 +210,7 @@ jobs: # home-assistant/wheels doesn't support sha pinning - name: Build wheels - uses: home-assistant/wheels@2025.09.1 + uses: *home-assistant-wheels with: abi: ${{ matrix.abi }} tag: musllinux_1_2 diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py index 2128d874390f83..4c328571d0e5ac 100644 --- a/homeassistant/auth/mfa_modules/totp.py +++ b/homeassistant/auth/mfa_modules/totp.py @@ -34,6 +34,9 @@ DUMMY_SECRET = "FPPTH34D4E3MI2HG" +GOOGLE_AUTHENTICATOR_URL = "https://support.google.com/accounts/answer/1066447" +AUTHY_URL = "https://authy.com/" + def _generate_qr_code(data: str) -> str: """Generate a base64 PNG string represent QR Code image of data.""" @@ -229,6 +232,8 @@ async def async_step_init( "code": self._ota_secret, "url": self._url, "qr_code": self._image, + "google_authenticator_url": GOOGLE_AUTHENTICATOR_URL, + "authy_url": AUTHY_URL, }, errors=errors, ) diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py index 661e1b0a298718..331fdb729f5770 100644 --- a/homeassistant/components/airnow/config_flow.py +++ b/homeassistant/components/airnow/config_flow.py @@ -26,6 +26,10 @@ _LOGGER = logging.getLogger(__name__) +# Documentation URL for API key generation +_API_KEY_URL = "https://docs.airnowapi.org/account/request/" + + async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool: """Validate the user input allows us to connect. @@ -114,6 +118,7 @@ async def async_step_user( ), } ), + description_placeholders={"api_key_url": _API_KEY_URL}, errors=errors, ) diff --git a/homeassistant/components/airnow/strings.json b/homeassistant/components/airnow/strings.json index a69f67948cb858..f6ccf858314666 100644 --- a/homeassistant/components/airnow/strings.json +++ b/homeassistant/components/airnow/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "To generate API key go to https://docs.airnowapi.org/account/request/", + "description": "To generate API key go to {api_key_url}", "data": { "api_key": "[%key:common::config_flow::data::api_key%]", "latitude": "[%key:common::config_flow::data::latitude%]", diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 9e7b30afa13ccf..13b5b21536e5cc 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -41,6 +41,11 @@ CONF_APP_DELETE = "app_delete" CONF_APP_ID = "app_id" +_EXAMPLE_APP_ID = "com.plexapp.android" +_EXAMPLE_APP_PLAY_STORE_URL = ( + f"https://play.google.com/store/apps/details?id={_EXAMPLE_APP_ID}" +) + STEP_PAIR_DATA_SCHEMA = vol.Schema( { vol.Required("pin"): str, @@ -355,5 +360,7 @@ def _async_apps_form(self, app_id: str) -> ConfigFlowResult: data_schema=data_schema, description_placeholders={ "app_id": f"`{app_id}`" if app_id != APPS_NEW_ID else "", + "example_app_id": _EXAMPLE_APP_ID, + "example_app_play_store_url": _EXAMPLE_APP_PLAY_STORE_URL, }, ) diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json index 971ee477b746e2..b9a7db74bbeb85 100644 --- a/homeassistant/components/androidtv_remote/strings.json +++ b/homeassistant/components/androidtv_remote/strings.json @@ -75,7 +75,7 @@ }, "data_description": { "app_name": "Name of the application as you would like it to be displayed in Home Assistant.", - "app_id": "E.g. com.plexapp.android for https://play.google.com/store/apps/details?id=com.plexapp.android", + "app_id": "E.g. {example_app_id} for {example_app_play_store_url}", "app_icon": "Image URL. From the Play Store app page, right click on the icon and select 'Copy image address' and then paste it here. Alternatively, download the image, upload it under /config/www/ and use the URL /local/filename", "app_delete": "Check this box to delete the application from the list." } diff --git a/homeassistant/components/auth/strings.json b/homeassistant/components/auth/strings.json index b1e80d716d898e..cae69673cebd45 100644 --- a/homeassistant/components/auth/strings.json +++ b/homeassistant/components/auth/strings.json @@ -5,7 +5,7 @@ "step": { "init": { "title": "Set up two-factor authentication using TOTP", - "description": "To activate two-factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator](https://support.google.com/accounts/answer/1066447) or [Authy](https://authy.com/).\n\n{qr_code}\n\nAfter scanning the code, enter the six-digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**." + "description": "To activate two-factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator]({google_authenticator_url}) or [Authy]({authy_url}).\n\n{qr_code}\n\nAfter scanning the code, enter the six-digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**." } }, "error": { diff --git a/homeassistant/components/compit/config_flow.py b/homeassistant/components/compit/config_flow.py index 3f41aec8f13143..fc2cac432b1d49 100644 --- a/homeassistant/components/compit/config_flow.py +++ b/homeassistant/components/compit/config_flow.py @@ -78,7 +78,10 @@ async def async_step_user( ) return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + description_placeholders={"compit_url": "https://inext.compit.pl/"}, ) async def async_step_reauth(self, data: Mapping[str, Any]) -> ConfigFlowResult: diff --git a/homeassistant/components/compit/strings.json b/homeassistant/components/compit/strings.json index c043fe525f2554..3daefd25f66272 100644 --- a/homeassistant/components/compit/strings.json +++ b/homeassistant/components/compit/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "Please enter your https://inext.compit.pl/ credentials.", + "description": "Please enter your {compit_url} credentials.", "title": "Connect to Compit iNext", "data": { "email": "[%key:common::config_flow::data::email%]", diff --git a/homeassistant/components/firefly_iii/diagnostics.py b/homeassistant/components/firefly_iii/diagnostics.py new file mode 100644 index 00000000000000..6b3a6a139402cc --- /dev/null +++ b/homeassistant/components/firefly_iii/diagnostics.py @@ -0,0 +1,26 @@ +"""Diagnostics for the Firefly III integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_API_KEY, CONF_URL +from homeassistant.core import HomeAssistant + +from . import FireflyConfigEntry +from .coordinator import FireflyDataUpdateCoordinator + +TO_REDACT = [CONF_API_KEY, CONF_URL] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: FireflyConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: FireflyDataUpdateCoordinator = entry.runtime_data + + return { + "config_entry": async_redact_data(entry.as_dict(), TO_REDACT), + "data": {"primary_currency": coordinator.data.primary_currency.to_dict()}, + } diff --git a/homeassistant/components/flume/config_flow.py b/homeassistant/components/flume/config_flow.py index cbe3f4983fa437..bdd4eb4cf51a15 100644 --- a/homeassistant/components/flume/config_flow.py +++ b/homeassistant/components/flume/config_flow.py @@ -111,7 +111,12 @@ async def async_step_user( errors[CONF_PASSWORD] = "invalid_auth" return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="user", + data_schema=DATA_SCHEMA, + errors=errors, + description_placeholders={ + "api_url": "https://portal.flumetech.com/settings#token" + }, ) async def async_step_reauth( diff --git a/homeassistant/components/flume/strings.json b/homeassistant/components/flume/strings.json index acdb8e35fe06a0..e138da2c4f0bee 100644 --- a/homeassistant/components/flume/strings.json +++ b/homeassistant/components/flume/strings.json @@ -7,7 +7,7 @@ }, "step": { "user": { - "description": "In order to access the Flume Personal API, you will need to request a 'Client ID' and 'Client Secret' at https://portal.flumetech.com/settings#token", + "description": "In order to access the Flume Personal API, you will need to request a 'Client ID' and 'Client Secret' at {api_url}", "title": "Connect to your Flume account", "data": { "username": "[%key:common::config_flow::data::username%]", diff --git a/homeassistant/components/freedompro/config_flow.py b/homeassistant/components/freedompro/config_flow.py index 48d075f8a87e4b..17234213fc5251 100644 --- a/homeassistant/components/freedompro/config_flow.py +++ b/homeassistant/components/freedompro/config_flow.py @@ -14,6 +14,7 @@ from .const import DOMAIN STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) +API_KEY_URL = "https://freedompro.eu/" class Hub: @@ -53,7 +54,11 @@ async def async_step_user( """Show the setup form to the user.""" if user_input is None: return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + description_placeholders={ + "api_key_url": API_KEY_URL, + }, ) errors = {} @@ -68,7 +73,12 @@ async def async_step_user( return self.async_create_entry(title="Freedompro", data=user_input) return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + description_placeholders={ + "api_key_url": API_KEY_URL, + }, ) diff --git a/homeassistant/components/freedompro/strings.json b/homeassistant/components/freedompro/strings.json index 947a9bd2e33b15..01316aadc34b00 100644 --- a/homeassistant/components/freedompro/strings.json +++ b/homeassistant/components/freedompro/strings.json @@ -5,7 +5,7 @@ "data": { "api_key": "[%key:common::config_flow::data::api_key%]" }, - "description": "Please enter the API key obtained from https://home.freedompro.eu", + "description": "Please enter the API key obtained from {api_key_url}", "title": "Freedompro API key" } }, diff --git a/homeassistant/components/gardena_bluetooth/config_flow.py b/homeassistant/components/gardena_bluetooth/config_flow.py index 613d0cf21dbb2d..47db758c7897a7 100644 --- a/homeassistant/components/gardena_bluetooth/config_flow.py +++ b/homeassistant/components/gardena_bluetooth/config_flow.py @@ -128,7 +128,7 @@ async def async_step_user( self._abort_if_unique_id_configured() return await self.async_step_confirm() - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass): address = discovery_info.address if address in current_addresses or not _is_supported(discovery_info): diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index 564a9f12ed9920..585398205f62e9 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -55,7 +55,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .entity import GroupEntity -from .util import find_state_attributes, mean_tuple, reduce_attribute +from .util import find_state_attributes, mean_circle, mean_tuple, reduce_attribute DEFAULT_NAME = "Light Group" CONF_ALL = "all" @@ -229,7 +229,7 @@ def async_update_group_state(self) -> None: self._attr_brightness = reduce_attribute(on_states, ATTR_BRIGHTNESS) self._attr_hs_color = reduce_attribute( - on_states, ATTR_HS_COLOR, reduce=mean_tuple + on_states, ATTR_HS_COLOR, reduce=mean_circle ) self._attr_rgb_color = reduce_attribute( on_states, ATTR_RGB_COLOR, reduce=mean_tuple diff --git a/homeassistant/components/group/util.py b/homeassistant/components/group/util.py index 1ba8934d0212aa..6d5f875713b222 100644 --- a/homeassistant/components/group/util.py +++ b/homeassistant/components/group/util.py @@ -4,6 +4,7 @@ from collections.abc import Callable, Iterator from itertools import groupby +from math import atan2, cos, degrees, radians, sin from typing import Any from homeassistant.core import State @@ -32,6 +33,23 @@ def mean_tuple(*args: Any) -> tuple[float | Any, ...]: return tuple(sum(x) / len(x) for x in zip(*args, strict=False)) +def mean_circle(*args: Any) -> tuple[float | Any, ...]: + """Return the circular mean of hue values and arithmetic mean of saturation values from HS color tuples.""" + if not args: + return () + + hues, saturations = zip(*args, strict=False) + + sum_x = sum(cos(radians(h)) for h in hues) + sum_y = sum(sin(radians(h)) for h in hues) + + mean_angle = degrees(atan2(sum_y, sum_x)) % 360 + + saturation = sum(saturations) / len(saturations) + + return (mean_angle, saturation) + + def attribute_equal(states: list[State], key: str) -> bool: """Return True if all attributes found matching key from states are equal. diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index e9e2ae09350cdb..74141c0083765e 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -72,8 +72,7 @@ async def async_setup_entry( config_entry.runtime_data = coordinator party = coordinator.data.user.party.id - if HABITICA_KEY not in hass.data: - hass.data[HABITICA_KEY] = {} + hass.data.setdefault(HABITICA_KEY, {}) if party is not None and party not in hass.data[HABITICA_KEY]: party_coordinator = HabiticaPartyCoordinator(hass, config_entry, api) @@ -117,9 +116,20 @@ def _party_update_listener() -> None: coordinator.async_add_listener(_party_update_listener) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + config_entry.async_on_unload( + config_entry.add_update_listener(_async_update_listener) + ) return True +async def _async_update_listener( + hass: HomeAssistant, entry: HabiticaConfigEntry +) -> None: + """Handle update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def shutdown_party_coordinator(hass: HomeAssistant, party_added: UUID) -> None: """Handle party coordinator shutdown.""" await hass.data[HABITICA_KEY][party_added].async_shutdown() diff --git a/homeassistant/components/habitica/config_flow.py b/homeassistant/components/habitica/config_flow.py index 65d9be1bb7c37a..2f605e4ba94ed9 100644 --- a/homeassistant/components/habitica/config_flow.py +++ b/homeassistant/components/habitica/config_flow.py @@ -5,6 +5,7 @@ from collections.abc import Mapping import logging from typing import TYPE_CHECKING, Any +from uuid import UUID from aiohttp import ClientError from habiticalib import ( @@ -17,7 +18,14 @@ import voluptuous as vol from homeassistant import data_entry_flow -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + ConfigEntry, + ConfigEntryState, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) from homeassistant.const import ( CONF_API_KEY, CONF_NAME, @@ -26,15 +34,21 @@ CONF_USERNAME, CONF_VERIFY_SSL, ) +from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, TextSelector, TextSelectorConfig, TextSelectorType, ) +from . import HABITICA_KEY from .const import ( CONF_API_USER, + CONF_PARTY_MEMBER, DEFAULT_URL, DOMAIN, FORGOT_PASSWORD_URL, @@ -374,3 +388,66 @@ async def validate_api_key( return errors, user.data return errors, None + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {"party_member": PartyMembersSubentryFlowHandler} + + +class PartyMembersSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow for adding party members.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Subentry user flow.""" + + entry: HabiticaConfigEntry = self._get_entry() + if entry.state is not ConfigEntryState.LOADED: + return self.async_abort(reason="config_entry_disabled") + if (party := entry.runtime_data.data.user.party.id) is None: + return self.async_abort(reason="not_in_a_party") + + party_members = self.hass.data[HABITICA_KEY][party].data.members + + if user_input is not None: + config_entries = self.hass.config_entries.async_entries(DOMAIN) + + for entry in config_entries: + if user_input[CONF_PARTY_MEMBER] == entry.unique_id: + return self.async_abort(reason="already_configured_as_entry") + if user_input[CONF_PARTY_MEMBER] in { + subentry.unique_id for subentry in entry.subentries.values() + }: + return self.async_abort(reason="already_configured") + + return self.async_create_entry( + title=party_members[UUID(user_input[CONF_PARTY_MEMBER])].profile.name, + data={}, + unique_id=user_input[CONF_PARTY_MEMBER], + ) + + options = [ + SelectOptionDict( + value=str(member_id), + label=f"{member.profile.name} (@{member.auth.local.username})", + ) + for member_id, member in party_members.items() + if member_id != str(entry.runtime_data.data.user.id) + and member.profile.name + and member.auth.local.username + ] + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_PARTY_MEMBER): SelectSelector( + SelectSelectorConfig(options=options) + ) + } + ), + ) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index a32179889cfc83..dfcbecb5f05a49 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -3,6 +3,7 @@ from homeassistant.const import APPLICATION_NAME, __version__ CONF_API_USER = "api_user" +CONF_PARTY_MEMBER = "party_member" DEFAULT_URL = "https://habitica.com" ASSETS_URL = "https://habitica-assets.s3.amazonaws.com/mobileApp/images/" diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index 94de7cc152385d..bb0c8e0577cc62 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -213,7 +213,9 @@ async def _update_data(self) -> HabiticaPartyData: party=(await self.habitica.get_group()).data, members={ member.id: member - for member in (await self.habitica.get_group_members()).data + for member in ( + await self.habitica.get_group_members(public_fields=True) + ).data if member.id }, ) diff --git a/homeassistant/components/habitica/entity.py b/homeassistant/components/habitica/entity.py index 4d82815956b9c4..e4fff926aceb39 100644 --- a/homeassistant/components/habitica/entity.py +++ b/homeassistant/components/habitica/entity.py @@ -3,10 +3,12 @@ from __future__ import annotations from typing import TYPE_CHECKING +from uuid import UUID -from habiticalib import ContentData +from habiticalib import ContentData, UserData from yarl import URL +from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_URL from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription @@ -29,26 +31,84 @@ def __init__( self, coordinator: HabiticaDataUpdateCoordinator, entity_description: EntityDescription, + subentry: ConfigSubentry | None = None, ) -> None: """Initialize a Habitica entity.""" super().__init__(coordinator) if TYPE_CHECKING: assert coordinator.config_entry.unique_id + assert self.user self.entity_description = entity_description - self._attr_unique_id = ( - f"{coordinator.config_entry.unique_id}_{entity_description.key}" + self.subentry = subentry + unique_id = ( + subentry.unique_id + if subentry is not None and subentry.unique_id + else coordinator.config_entry.unique_id ) + + self._attr_unique_id = f"{unique_id}_{entity_description.key}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, manufacturer=MANUFACTURER, model=NAME, - name=coordinator.data.user.profile.name, + name=self.user.profile.name, configuration_url=( - URL(coordinator.config_entry.data[CONF_URL]) - / "profile" - / coordinator.config_entry.unique_id + URL(coordinator.config_entry.data[CONF_URL]) / "profile" / unique_id ), - identifiers={(DOMAIN, coordinator.config_entry.unique_id)}, + identifiers={(DOMAIN, unique_id)}, + ) + + if subentry: + self._attr_device_info.update( + DeviceInfo( + via_device=( + ( + DOMAIN, + f"{coordinator.config_entry.unique_id}_{self.user.party.id}", + ) + ) + ) + ) + + @property + def user(self) -> UserData | None: + """Return the user data.""" + return self.coordinator.data.user + + +class HabiticaPartyMemberBase(HabiticaBase): + """Base Habitica party member entity.""" + + def __init__( + self, + coordinator: HabiticaDataUpdateCoordinator, + party_coordinator: HabiticaPartyCoordinator, + entity_description: EntityDescription, + subentry: ConfigSubentry | None = None, + ) -> None: + """Initialize a Habitica entity.""" + self.party_coordinator = party_coordinator + super().__init__(coordinator, entity_description, subentry) + + @property + def user(self) -> UserData | None: + """Return the user data of the party member.""" + if TYPE_CHECKING: + assert self.subentry + assert self.subentry.unique_id + return self.party_coordinator.data.members.get(UUID(self.subentry.unique_id)) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + + return super().available and self.user is not None + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.party_coordinator.async_add_listener(self._handle_coordinator_update) ) diff --git a/homeassistant/components/habitica/image.py b/homeassistant/components/habitica/image.py index 15efc8e6667f23..d227aa1f2f1e0b 100644 --- a/homeassistant/components/habitica/image.py +++ b/homeassistant/components/habitica/image.py @@ -3,10 +3,13 @@ from __future__ import annotations from enum import StrEnum +from typing import TYPE_CHECKING +from uuid import UUID from habiticalib import Avatar, ContentData, extract_avatar from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription +from homeassistant.config_entries import ConfigSubentry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util @@ -18,7 +21,7 @@ HabiticaDataUpdateCoordinator, HabiticaPartyCoordinator, ) -from .entity import HabiticaBase, HabiticaPartyBase +from .entity import HabiticaBase, HabiticaPartyBase, HabiticaPartyMemberBase PARALLEL_UPDATES = 1 @@ -47,6 +50,22 @@ async def async_setup_entry( hass, party_coordinator, config_entry, coordinator.content ) ) + for subentry_id, subentry in config_entry.subentries.items(): + if ( + subentry.unique_id + and UUID(subentry.unique_id) in party_coordinator.data.members + ): + async_add_entities( + [ + HabiticaPartyMemberImage( + hass, + coordinator, + party_coordinator, + subentry, + ) + ], + config_subentry_id=subentry_id, + ) async_add_entities(entities) @@ -66,18 +85,21 @@ def __init__( self, hass: HomeAssistant, coordinator: HabiticaDataUpdateCoordinator, + subentry: ConfigSubentry | None = None, ) -> None: """Initialize the image entity.""" - super().__init__(coordinator, self.entity_description) + HabiticaBase.__init__(self, coordinator, self.entity_description, subentry) ImageEntity.__init__(self, hass) self._attr_image_last_updated = dt_util.utcnow() - self._avatar = extract_avatar(self.coordinator.data.user) + if TYPE_CHECKING: + assert self.user + self._avatar = extract_avatar(self.user) def _handle_coordinator_update(self) -> None: """Check if equipped gear and other things have changed since last avatar image generation.""" - if self._avatar != self.coordinator.data.user: - self._avatar = extract_avatar(self.coordinator.data.user) + if self.user is not None and self._avatar != self.user: + self._avatar = extract_avatar(self.user) self._attr_image_last_updated = dt_util.utcnow() self._cache = None @@ -90,6 +112,24 @@ async def async_image(self) -> bytes | None: return self._cache +class HabiticaPartyMemberImage(HabiticaImage, HabiticaPartyMemberBase): + """A Habitica party member image entity.""" + + def __init__( + self, + hass: HomeAssistant, + coordinator: HabiticaDataUpdateCoordinator, + party_coordinator: HabiticaPartyCoordinator, + subentry: ConfigSubentry | None = None, + ) -> None: + """Initialize the image entity.""" + + HabiticaPartyMemberBase.__init__( + self, coordinator, party_coordinator, self.entity_description, subentry + ) + super().__init__(hass, coordinator, subentry) + + class HabiticaPartyImage(HabiticaPartyBase, ImageEntity): """A Habitica image entity of a party.""" diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index 93cb01f1304f4b..9649723f761084 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@tr4nt0r"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/habitica", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["habiticalib"], "quality_scale": "platinum", diff --git a/homeassistant/components/habitica/quality_scale.yaml b/homeassistant/components/habitica/quality_scale.yaml index c5131b81a4d39e..1a4ed97622ae4b 100644 --- a/homeassistant/components/habitica/quality_scale.yaml +++ b/homeassistant/components/habitica/quality_scale.yaml @@ -68,8 +68,8 @@ rules: icon-translations: done reconfiguration-flow: done repair-issues: - status: done - comment: Used to inform of deprecated entities and actions. + status: exempt + comment: Integration has no repairs stale-devices: status: done comment: Party device is remove if stale. diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index d13b5562cd6c58..824d5c414fdb37 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -8,6 +8,7 @@ from enum import StrEnum import logging from typing import Any +from uuid import UUID from habiticalib import ContentData, GroupData, HabiticaClass, TaskData, UserData, ha @@ -24,7 +25,7 @@ from . import HABITICA_KEY from .const import ASSETS_URL from .coordinator import HabiticaConfigEntry -from .entity import HabiticaBase, HabiticaPartyBase +from .entity import HabiticaBase, HabiticaPartyBase, HabiticaPartyMemberBase from .util import ( collected_quest_items, get_attribute_points, @@ -118,12 +119,13 @@ class HabiticaSensorEntity(StrEnum): LAST_CHECKIN = "last_checkin" -SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( +SENSOR_DESCRIPTIONS_COMMON: tuple[HabiticaSensorEntityDescription, ...] = ( HabiticaSensorEntityDescription( key=HabiticaSensorEntity.DISPLAY_NAME, translation_key=HabiticaSensorEntity.DISPLAY_NAME, value_fn=lambda user, _: user.profile.name, attributes_fn=lambda user, _: { + "username": f"@{user.auth.local.username}", "blurb": user.profile.blurb, "joined": ( dt_util.as_local(joined).date() @@ -175,13 +177,6 @@ class HabiticaSensorEntity(StrEnum): translation_key=HabiticaSensorEntity.LEVEL, value_fn=lambda user, _: user.stats.lvl, ), - HabiticaSensorEntityDescription( - key=HabiticaSensorEntity.GOLD, - translation_key=HabiticaSensorEntity.GOLD, - suggested_display_precision=2, - value_fn=lambda user, _: user.stats.gp, - entity_picture=ha.GP, - ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.CLASS, translation_key=HabiticaSensorEntity.CLASS, @@ -189,21 +184,6 @@ class HabiticaSensorEntity(StrEnum): device_class=SensorDeviceClass.ENUM, options=[item.value for item in HabiticaClass], ), - HabiticaSensorEntityDescription( - key=HabiticaSensorEntity.GEMS, - translation_key=HabiticaSensorEntity.GEMS, - value_fn=lambda user, _: None if (b := user.balance) is None else round(b * 4), - suggested_display_precision=0, - entity_picture="shop_gem.png", - ), - HabiticaSensorEntityDescription( - key=HabiticaSensorEntity.TRINKETS, - translation_key=HabiticaSensorEntity.TRINKETS, - value_fn=lambda user, _: user.purchased.plan.consecutive.trinkets, - suggested_display_precision=0, - native_unit_of_measurement="⧖", - entity_picture="notif_subscriber_reward.png", - ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.STRENGTH, translation_key=HabiticaSensorEntity.STRENGTH, @@ -236,6 +216,40 @@ class HabiticaSensorEntity(StrEnum): suggested_display_precision=0, native_unit_of_measurement="CON", ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.LAST_CHECKIN, + translation_key=HabiticaSensorEntity.LAST_CHECKIN, + value_fn=( + lambda user, _: dt_util.as_local(last) + if (last := user.auth.timestamps.loggedin) + else None + ), + device_class=SensorDeviceClass.TIMESTAMP, + ), +) +SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.GOLD, + translation_key=HabiticaSensorEntity.GOLD, + suggested_display_precision=2, + value_fn=lambda user, _: user.stats.gp, + entity_picture=ha.GP, + ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.GEMS, + translation_key=HabiticaSensorEntity.GEMS, + value_fn=lambda user, _: None if (b := user.balance) is None else round(b * 4), + suggested_display_precision=0, + entity_picture="shop_gem.png", + ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.TRINKETS, + translation_key=HabiticaSensorEntity.TRINKETS, + value_fn=lambda user, _: user.purchased.plan.consecutive.trinkets, + suggested_display_precision=0, + native_unit_of_measurement="⧖", + entity_picture="notif_subscriber_reward.png", + ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.EGGS_TOTAL, translation_key=HabiticaSensorEntity.EGGS_TOTAL, @@ -286,16 +300,6 @@ class HabiticaSensorEntity(StrEnum): translation_key=HabiticaSensorEntity.PENDING_QUEST_ITEMS, value_fn=pending_quest_items, ), - HabiticaSensorEntityDescription( - key=HabiticaSensorEntity.LAST_CHECKIN, - translation_key=HabiticaSensorEntity.LAST_CHECKIN, - value_fn=( - lambda user, _: dt_util.as_local(last) - if (last := user.auth.timestamps.loggedin) - else None - ), - device_class=SensorDeviceClass.TIMESTAMP, - ), ) @@ -389,7 +393,8 @@ async def async_setup_entry( coordinator = config_entry.runtime_data async_add_entities( - HabiticaSensor(coordinator, description) for description in SENSOR_DESCRIPTIONS + HabiticaSensor(coordinator, description) + for description in SENSOR_DESCRIPTIONS + SENSOR_DESCRIPTIONS_COMMON ) if party := coordinator.data.user.party.id: @@ -403,6 +408,23 @@ async def async_setup_entry( ) for description in SENSOR_DESCRIPTIONS_PARTY ) + for subentry_id, subentry in config_entry.subentries.items(): + if ( + subentry.unique_id + and UUID(subentry.unique_id) in party_coordinator.data.members + ): + async_add_entities( + [ + HabiticaPartyMemberSensor( + coordinator, + party_coordinator, + description, + subentry, + ) + for description in SENSOR_DESCRIPTIONS_COMMON + ], + config_subentry_id=subentry_id, + ) class HabiticaSensor(HabiticaBase, SensorEntity): @@ -414,27 +436,33 @@ class HabiticaSensor(HabiticaBase, SensorEntity): def native_value(self) -> StateType | datetime: """Return the state of the device.""" - return self.entity_description.value_fn( - self.coordinator.data.user, self.coordinator.content + return ( + self.entity_description.value_fn(self.user, self.coordinator.content) + if self.user is not None + else None ) @property def extra_state_attributes(self) -> dict[str, float | None] | None: """Return entity specific state attributes.""" - if func := self.entity_description.attributes_fn: - return func(self.coordinator.data.user, self.coordinator.content) + if self.user is not None and (func := self.entity_description.attributes_fn): + return func(self.user, self.coordinator.content) return None @property def entity_picture(self) -> str | None: """Return the entity picture to use in the frontend, if any.""" - if self.entity_description.key is HabiticaSensorEntity.CLASS and ( - _class := self.coordinator.data.user.stats.Class + if ( + self.entity_description.key is HabiticaSensorEntity.CLASS + and self.user is not None + and (_class := self.user.stats.Class) ): return SVG_CLASS[_class] - if self.entity_description.key is HabiticaSensorEntity.DISPLAY_NAME and ( - img_url := self.coordinator.data.user.profile.imageUrl + if ( + self.entity_description.key is HabiticaSensorEntity.DISPLAY_NAME + and self.user is not None + and (img_url := self.user.profile.imageUrl) ): return img_url @@ -448,6 +476,10 @@ def entity_picture(self) -> str | None: return None +class HabiticaPartyMemberSensor(HabiticaSensor, HabiticaPartyMemberBase): + """Habitica party member sensor.""" + + class HabiticaPartySensor(HabiticaPartyBase, SensorEntity): """Habitica party sensor.""" diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 53e570bd978a9c..cd4b359c299422 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -174,6 +174,32 @@ } } }, + "config_subentries": { + "party_member": { + "step": { + "user": { + "title": "Party members", + "description": "Track the stats of the adventurers in your party.", + "data": { + "party_member": "Party member" + }, + "data_description": { + "party_member": "Select an adventurer from your party to track health and other stats." + } + } + }, + "initiate_flow": { + "user": "Add party member" + }, + "entry_type": "Party member", + "abort": { + "already_configured_as_entry": "Already configured as a user. This adventurer cannot be added as a party member.", + "already_configured": "This adventurer is already configured as a party member in this or another account.", + "config_entry_disabled": "Cannot add party members when the main account is disabled or not loaded.", + "not_in_a_party": "You are currently not in a party. You can only add party members when your character is in a party." + } + } + }, "entity": { "binary_sensor": { "pending_quest": { @@ -287,6 +313,9 @@ }, "total_logins": { "name": "Total logins" + }, + "username": { + "name": "[%key:common::config_flow::data::username%]" } } }, @@ -591,12 +620,6 @@ "message": "Unable to send message, {name} not found. ({reason})" } }, - "issues": { - "deprecated_entity": { - "title": "The Habitica {name} entity is deprecated", - "description": "The Habitica entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue." - } - }, "services": { "cast_skill": { "name": "Cast a skill", diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index 8323c0e19958af..cd85de5c62787b 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -202,5 +202,10 @@ async def async_step_connect( ) return self.async_show_form( - step_id="connect", data_schema=self._config_settings, errors=errors + step_id="connect", + data_schema=self._config_settings, + errors=errors, + description_placeholders={ + "documentation_url": "https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-api-key", + }, ) diff --git a/homeassistant/components/motion_blinds/strings.json b/homeassistant/components/motion_blinds/strings.json index 12060cd69f0473..4d93dcef6c0750 100644 --- a/homeassistant/components/motion_blinds/strings.json +++ b/homeassistant/components/motion_blinds/strings.json @@ -9,7 +9,7 @@ } }, "connect": { - "description": "You will need the 16 character API key, see https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-api-key for instructions", + "description": "You will need the 16 character API key, see {documentation_url} for instructions", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" } diff --git a/homeassistant/components/ntfy/manifest.json b/homeassistant/components/ntfy/manifest.json index 279d30d9f9fbc8..9d0960dfbf6902 100644 --- a/homeassistant/components/ntfy/manifest.json +++ b/homeassistant/components/ntfy/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@tr4nt0r"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ntfy", + "integration_type": "service", "iot_class": "cloud_push", "loggers": ["aionfty"], "quality_scale": "platinum", diff --git a/homeassistant/components/osoenergy/config_flow.py b/homeassistant/components/osoenergy/config_flow.py index a47f90e3c04379..3a6efc28ac11bc 100644 --- a/homeassistant/components/osoenergy/config_flow.py +++ b/homeassistant/components/osoenergy/config_flow.py @@ -16,6 +16,9 @@ _LOGGER = logging.getLogger(__name__) _SCHEMA_STEP_USER = vol.Schema({vol.Required(CONF_API_KEY): str}) +CONF_PORTAL_URL = "portal_url" +OSOENERGY_PORTAL_URL = "https://portal.osoenergy.no/" + class OSOEnergyFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a OSO Energy config flow.""" @@ -45,6 +48,7 @@ async def async_step_user(self, user_input=None) -> ConfigFlowResult: step_id="user", data_schema=_SCHEMA_STEP_USER, errors=errors, + description_placeholders={CONF_PORTAL_URL: OSOENERGY_PORTAL_URL}, ) async def get_user_email(self, subscription_key: str) -> str | None: @@ -66,4 +70,5 @@ async def async_step_reauth( data_schema=self.add_suggested_values_to_schema( _SCHEMA_STEP_USER, self._get_reauth_entry().data ), + description_placeholders={CONF_PORTAL_URL: OSOENERGY_PORTAL_URL}, ) diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json index 48b99749ca10f9..27129579e2ee8a 100644 --- a/homeassistant/components/osoenergy/strings.json +++ b/homeassistant/components/osoenergy/strings.json @@ -3,14 +3,14 @@ "step": { "user": { "title": "OSO Energy auth", - "description": "Enter the 'Subscription key' for your account generated at 'https://portal.osoenergy.no/'", + "description": "Enter the 'Subscription key' for your account generated at {portal_url}", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" } }, "reauth": { "title": "OSO Energy auth", - "description": "Enter a new 'Subscription key' for your account generated at 'https://portal.osoenergy.no/'.", + "description": "Enter a new 'Subscription key' for your account generated at {portal_url}", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" } diff --git a/homeassistant/components/rachio/config_flow.py b/homeassistant/components/rachio/config_flow.py index cc32bd2e56f6dc..4956d204a98cc5 100644 --- a/homeassistant/components/rachio/config_flow.py +++ b/homeassistant/components/rachio/config_flow.py @@ -91,7 +91,12 @@ async def async_step_user( errors["base"] = "unknown" return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="user", + data_schema=DATA_SCHEMA, + errors=errors, + description_placeholders={ + "api_key_url": "https://app.rach.io/", + }, ) async def async_step_homekit( diff --git a/homeassistant/components/rachio/strings.json b/homeassistant/components/rachio/strings.json index ea3c8911463d38..f63454cb185a39 100644 --- a/homeassistant/components/rachio/strings.json +++ b/homeassistant/components/rachio/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Connect to your Rachio device", - "description": "You will need the API key from https://app.rach.io/. Go to Settings, then select 'GET API KEY'.", + "description": "You will need the API key from {api_key_url}. Go to Settings, then select 'GET API KEY'.", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" } diff --git a/homeassistant/components/sensorpush_cloud/config_flow.py b/homeassistant/components/sensorpush_cloud/config_flow.py index d06fde2eba1e8c..8cf68e091349a9 100644 --- a/homeassistant/components/sensorpush_cloud/config_flow.py +++ b/homeassistant/components/sensorpush_cloud/config_flow.py @@ -61,4 +61,7 @@ async def async_step_user( } ), errors=errors, + description_placeholders={ + "dashboard_url": "https://dashboard.sensorpush.com/", + }, ) diff --git a/homeassistant/components/sensorpush_cloud/strings.json b/homeassistant/components/sensorpush_cloud/strings.json index 8467a123b6f806..15db60e0a10db0 100644 --- a/homeassistant/components/sensorpush_cloud/strings.json +++ b/homeassistant/components/sensorpush_cloud/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "To activate API access, log in to the [Gateway Cloud Dashboard](https://dashboard.sensorpush.com/) and agree to the terms of service. Devices are not available until activated with the SensorPush app on iOS or Android.", + "description": "To activate API access, log in to the [Gateway Cloud Dashboard]({dashboard_url}) and agree to the terms of service. Devices are not available until activated with the SensorPush app on iOS or Android.", "data": { "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" diff --git a/homeassistant/components/simplisafe/config_flow.py b/homeassistant/components/simplisafe/config_flow.py index 68974fe118fee0..6494b84981bd4d 100644 --- a/homeassistant/components/simplisafe/config_flow.py +++ b/homeassistant/components/simplisafe/config_flow.py @@ -27,6 +27,10 @@ from .const import DOMAIN, LOGGER CONF_AUTH_CODE = "auth_code" +CONF_DOCUMENTATION_URL = "documentation_url" +DOCUMENTATION_URL = ( + "https://home-assistant.io/integrations/simplisafe#getting-an-authorization-code" +) STEP_USER_SCHEMA = vol.Schema( { @@ -84,7 +88,10 @@ async def async_step_user( return self.async_show_form( step_id="user", data_schema=STEP_USER_SCHEMA, - description_placeholders={CONF_URL: self._oauth_values.auth_url}, + description_placeholders={ + CONF_URL: self._oauth_values.auth_url, + CONF_DOCUMENTATION_URL: DOCUMENTATION_URL, + }, ) auth_code = user_input[CONF_AUTH_CODE] @@ -102,7 +109,10 @@ async def async_step_user( step_id="user", data_schema=STEP_USER_SCHEMA, errors={CONF_AUTH_CODE: "invalid_auth_code_length"}, - description_placeholders={CONF_URL: self._oauth_values.auth_url}, + description_placeholders={ + CONF_URL: self._oauth_values.auth_url, + CONF_DOCUMENTATION_URL: DOCUMENTATION_URL, + }, ) errors = {} @@ -124,7 +134,10 @@ async def async_step_user( step_id="user", data_schema=STEP_USER_SCHEMA, errors=errors, - description_placeholders={CONF_URL: self._oauth_values.auth_url}, + description_placeholders={ + CONF_URL: self._oauth_values.auth_url, + CONF_DOCUMENTATION_URL: DOCUMENTATION_URL, + }, ) simplisafe_user_id = str(simplisafe.user_id) diff --git a/homeassistant/components/simplisafe/strings.json b/homeassistant/components/simplisafe/strings.json index 992160350809a5..1f12b1ed65f7cb 100644 --- a/homeassistant/components/simplisafe/strings.json +++ b/homeassistant/components/simplisafe/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "SimpliSafe authenticates users via its web app. Due to technical limitations, there is a manual step at the end of this process; please ensure that you read the [documentation](http://home-assistant.io/integrations/simplisafe#getting-an-authorization-code) before starting.\n\nWhen you are ready, click [here]({url}) to open the SimpliSafe web app and input your credentials. If you've already logged into SimpliSafe in your browser, you may want to open a new tab, then copy/paste the above URL into that tab.\n\nWhen the process is complete, return here and input the authorization code from the `com.simplisafe.mobile` URL.", + "description": "SimpliSafe authenticates users via its web app. Due to technical limitations, there is a manual step at the end of this process; please ensure that you read the [documentation]({documentation_url}) before starting.\n\nWhen you are ready, click [here]({url}) to open the SimpliSafe web app and input your credentials. If you've already logged into SimpliSafe in your browser, you may want to open a new tab, then copy/paste the above URL into that tab.\n\nWhen the process is complete, return here and input the authorization code from the `com.simplisafe.mobile` URL.", "data": { "auth_code": "Authorization Code" } diff --git a/homeassistant/components/sleep_as_android/manifest.json b/homeassistant/components/sleep_as_android/manifest.json index f2b38d2089b163..cd661b33594d64 100644 --- a/homeassistant/components/sleep_as_android/manifest.json +++ b/homeassistant/components/sleep_as_android/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/sleep_as_android", + "integration_type": "service", "iot_class": "local_push", "quality_scale": "platinum" } diff --git a/homeassistant/components/starline/config_flow.py b/homeassistant/components/starline/config_flow.py index a899b562f3607e..0f1983fc21d7b3 100644 --- a/homeassistant/components/starline/config_flow.py +++ b/homeassistant/components/starline/config_flow.py @@ -117,6 +117,9 @@ def _async_form_auth_app(self, error: str | None = None) -> ConfigFlowResult: } ), errors=errors, + description_placeholders={ + "developer_account_url": "https://my.starline.ru/developer", + }, ) @callback diff --git a/homeassistant/components/starline/strings.json b/homeassistant/components/starline/strings.json index b3ce755778ee99..9452e0b475701d 100644 --- a/homeassistant/components/starline/strings.json +++ b/homeassistant/components/starline/strings.json @@ -3,7 +3,7 @@ "step": { "auth_app": { "title": "Application credentials", - "description": "Application ID and secret code from [StarLine developer account](https://my.starline.ru/developer)", + "description": "Application ID and secret code from [StarLine developer account]({developer_account_url})", "data": { "app_id": "App ID", "app_secret": "Secret" diff --git a/homeassistant/components/togrill/config_flow.py b/homeassistant/components/togrill/config_flow.py index 29d930e796198f..0415eadf260d01 100644 --- a/homeassistant/components/togrill/config_flow.py +++ b/homeassistant/components/togrill/config_flow.py @@ -114,7 +114,7 @@ async def async_step_user( self._discovery_infos[address] ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, True): address = discovery_info.address if ( diff --git a/homeassistant/components/togrill/coordinator.py b/homeassistant/components/togrill/coordinator.py index 16b9871dd3e9bf..4b50037c404174 100644 --- a/homeassistant/components/togrill/coordinator.py +++ b/homeassistant/components/togrill/coordinator.py @@ -199,7 +199,7 @@ async def _async_update_data(self) -> dict[tuple[int, int | None], Packet]: if self.client and not self.client.is_connected: await self.client.disconnect() self.client = None - self._async_request_refresh_soon() + self._debounced_refresh.async_schedule_call() raise DeviceFailed("Device was disconnected") client = await self._get_connected_client() @@ -212,26 +212,10 @@ async def _async_update_data(self) -> dict[tuple[int, int | None], Packet]: raise DeviceFailed(f"Device failed {exc}") from exc return self.data - @callback - def _async_request_refresh_soon(self) -> None: - """Request a refresh in the near future. - - This way have been called during an update and - would be ignored by debounce logic, so we delay - it by a slight amount to hopefully let the current - update finish first. - """ - - async def _delayed_refresh() -> None: - await asyncio.sleep(0.5) - await self.async_request_refresh() - - self.config_entry.async_create_task(self.hass, _delayed_refresh()) - @callback def _disconnected_callback(self) -> None: """Handle Bluetooth device being disconnected.""" - self._async_request_refresh_soon() + self._debounced_refresh.async_schedule_call() @callback def _async_handle_bluetooth_event( @@ -241,4 +225,4 @@ def _async_handle_bluetooth_event( ) -> None: """Handle a Bluetooth event.""" if isinstance(self.last_exception, DeviceNotFound): - self._async_request_refresh_soon() + self._debounced_refresh.async_schedule_call() diff --git a/homeassistant/components/tomorrowio/config_flow.py b/homeassistant/components/tomorrowio/config_flow.py index cce41b17498846..71674a646cb5ad 100644 --- a/homeassistant/components/tomorrowio/config_flow.py +++ b/homeassistant/components/tomorrowio/config_flow.py @@ -172,4 +172,7 @@ async def async_step_user( step_id="user", data_schema=_get_config_schema(self.hass, self.source, user_input), errors=errors, + description_placeholders={ + "signup_link": "[Tomorrow.io](https://app.tomorrow.io/signup)" + }, ) diff --git a/homeassistant/components/tomorrowio/strings.json b/homeassistant/components/tomorrowio/strings.json index 033b338f1a4c3e..457123a9febe29 100644 --- a/homeassistant/components/tomorrowio/strings.json +++ b/homeassistant/components/tomorrowio/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "To get an API key, sign up at [Tomorrow.io](https://app.tomorrow.io/signup).", + "description": "To get an API key, sign up at {signup_link}.", "data": { "name": "[%key:common::config_flow::data::name%]", "api_key": "[%key:common::config_flow::data::api_key%]", diff --git a/homeassistant/components/uptime_kuma/manifest.json b/homeassistant/components/uptime_kuma/manifest.json index 6ea7150f15d00c..6a19784c4894c2 100644 --- a/homeassistant/components/uptime_kuma/manifest.json +++ b/homeassistant/components/uptime_kuma/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@tr4nt0r"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/uptime_kuma", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pythonkuma"], "quality_scale": "platinum", diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 62e1c63129a772..7889fafcc9eca6 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -54,6 +54,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: XboxConfigEntry) -> bool await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await async_migrate_unique_id(hass, entry) return True @@ -61,3 +62,28 @@ async def async_unload_entry(hass: HomeAssistant, entry: XboxConfigEntry) -> boo """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_unique_id(hass: HomeAssistant, entry: XboxConfigEntry) -> bool: + """Migrate config entry. + + Migration requires runtime data + """ + + if entry.version == 1 and entry.minor_version < 2: + # Migrate unique_id from `xbox` to account xuid and + # change generic entry name to user's gamertag + return hass.config_entries.async_update_entry( + entry, + unique_id=entry.runtime_data.client.xuid, + title=( + entry.runtime_data.data.presence[ + entry.runtime_data.client.xuid + ].gamertag + if entry.title == "Home Assistant Cloud" + else entry.title + ), + minor_version=2, + ) + + return True diff --git a/homeassistant/components/xbox/config_flow.py b/homeassistant/components/xbox/config_flow.py index 86157be5d7f903..f50be700a3b71f 100644 --- a/homeassistant/components/xbox/config_flow.py +++ b/homeassistant/components/xbox/config_flow.py @@ -3,6 +3,11 @@ import logging from typing import Any +from xbox.webapi.api.client import XboxLiveClient +from xbox.webapi.authentication.manager import AuthenticationManager +from xbox.webapi.authentication.models import OAuth2TokenResponse +from xbox.webapi.common.signed_session import SignedSession + from homeassistant.config_entries import ConfigFlowResult from homeassistant.helpers import config_entry_oauth2_flow @@ -16,6 +21,8 @@ class OAuth2FlowHandler( DOMAIN = DOMAIN + MINOR_VERSION = 2 + @property def logger(self) -> logging.Logger: """Return logger.""" @@ -31,9 +38,23 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow start.""" - await self.async_set_unique_id(DOMAIN) if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") return await super().async_step_user(user_input) + + async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: + """Create an entry for the flow.""" + + async with SignedSession() as session: + auth = AuthenticationManager(session, "", "", "") + auth.oauth = OAuth2TokenResponse(**data["token"]) + await auth.refresh_tokens() + + client = XboxLiveClient(auth) + + me = await client.people.get_friends_own_batch([client.xuid]) + + await self.async_set_unique_id(client.xuid) + return self.async_create_entry(title=me.people[0].gamertag, data=data) diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a6409238a161ed..adcbc4275a8e33 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2601,7 +2601,7 @@ }, "habitica": { "name": "Habitica", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -4546,7 +4546,7 @@ }, "ntfy": { "name": "ntfy", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_push" }, @@ -6103,7 +6103,7 @@ }, "sleep_as_android": { "name": "Sleep as Android", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "local_push" }, @@ -7215,7 +7215,7 @@ }, "uptime_kuma": { "name": "Uptime Kuma", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, diff --git a/tests/components/airnow/test_config_flow.py b/tests/components/airnow/test_config_flow.py index 6507eea1fcb1de..759a21bc559d43 100644 --- a/tests/components/airnow/test_config_flow.py +++ b/tests/components/airnow/test_config_flow.py @@ -25,6 +25,9 @@ async def test_form( ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} + assert result["description_placeholders"] == { + "api_key_url": "https://docs.airnowapi.org/account/request/" + } result2 = await hass.config_entries.flow.async_configure(result["flow_id"], config) assert result2["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py index 86ae7ab6739244..efb19e99efff2e 100644 --- a/tests/components/androidtv_remote/test_config_flow.py +++ b/tests/components/androidtv_remote/test_config_flow.py @@ -1112,6 +1112,11 @@ async def test_options_flow( ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "apps" + assert result["description_placeholders"] == { + "app_id": "", + "example_app_id": "com.plexapp.android", + "example_app_play_store_url": "https://play.google.com/store/apps/details?id=com.plexapp.android", + } # test save value for new app result = await hass.config_entries.options.async_configure( diff --git a/tests/components/compit/test_config_flow.py b/tests/components/compit/test_config_flow.py index 2305187e0000e7..58f364c62bcaf2 100644 --- a/tests/components/compit/test_config_flow.py +++ b/tests/components/compit/test_config_flow.py @@ -27,6 +27,9 @@ async def test_async_step_user_success( ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER + assert result["description_placeholders"] == { + "compit_url": "https://inext.compit.pl/" + } result = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG_INPUT diff --git a/tests/components/firefly_iii/snapshots/test_diagnostics.ambr b/tests/components/firefly_iii/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..c0316c446e7bde --- /dev/null +++ b/tests/components/firefly_iii/snapshots/test_diagnostics.ambr @@ -0,0 +1,44 @@ +# serializer version: 1 +# name: test_get_config_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'api_key': '**REDACTED**', + 'url': '**REDACTED**', + 'verify_ssl': True, + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'firefly_iii', + 'entry_id': 'firefly_iii_test_entry_123', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Firefly III test', + 'unique_id': 'firefly_iii_test_unique_id_123', + 'version': 1, + }), + 'data': dict({ + 'primary_currency': dict({ + 'attributes': dict({ + 'code': 'AMS', + 'decimal_places': 2, + 'default': False, + 'enabled': True, + 'name': 'Ankh-Morpork dollar', + 'native': False, + 'symbol': 'AM$', + 'updated_at': '2018-09-17T12:46:47+01:00', + }), + 'id': '2', + 'type': 'currencies', + }), + }), + }) +# --- diff --git a/tests/components/firefly_iii/test_diagnostics.py b/tests/components/firefly_iii/test_diagnostics.py new file mode 100644 index 00000000000000..2700dc8ab400c3 --- /dev/null +++ b/tests/components/firefly_iii/test_diagnostics.py @@ -0,0 +1,35 @@ +"""Test the Firefly III component diagnostics.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_get_config_entry_diagnostics( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_firefly_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test if get_config_entry_diagnostics returns the correct data.""" + await setup_integration(hass, mock_config_entry) + + diagnostics_entry = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + assert diagnostics_entry == snapshot( + exclude=props( + "created_at", + "modified_at", + ) + ) diff --git a/tests/components/gardena_bluetooth/conftest.py b/tests/components/gardena_bluetooth/conftest.py index 0f877fce7db2e6..6726525a3174ed 100644 --- a/tests/components/gardena_bluetooth/conftest.py +++ b/tests/components/gardena_bluetooth/conftest.py @@ -25,7 +25,9 @@ def mock_entry(): """Create hass config fixture.""" return MockConfigEntry( - domain=DOMAIN, data={CONF_ADDRESS: WATER_TIMER_SERVICE_INFO.address} + domain=DOMAIN, + data={CONF_ADDRESS: WATER_TIMER_SERVICE_INFO.address}, + unique_id=WATER_TIMER_SERVICE_INFO.address, ) diff --git a/tests/components/gardena_bluetooth/test_config_flow.py b/tests/components/gardena_bluetooth/test_config_flow.py index b20395ec40f557..3181e602d59944 100644 --- a/tests/components/gardena_bluetooth/test_config_flow.py +++ b/tests/components/gardena_bluetooth/test_config_flow.py @@ -8,7 +8,9 @@ from homeassistant import config_entries from homeassistant.components.gardena_bluetooth.const import DOMAIN +from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from . import ( MISSING_MANUFACTURER_DATA_SERVICE_INFO, @@ -18,6 +20,7 @@ WATER_TIMER_UNNAMED_SERVICE_INFO, ) +from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -51,6 +54,39 @@ async def test_user_selection( assert result == snapshot +async def test_user_selection_replaces_ignored(hass: HomeAssistant) -> None: + """Test setup from service info cache replaces an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=WATER_TIMER_SERVICE_INFO.address, + ) + entry.source = config_entries.SOURCE_IGNORE + entry.add_to_hass(hass) + + inject_bluetooth_service_info(hass, WATER_TIMER_SERVICE_INFO) + await hass.async_block_till_done(wait_background_tasks=True) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_ADDRESS: WATER_TIMER_SERVICE_INFO.address}, + ) + + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + async def test_failed_connect( hass: HomeAssistant, mock_client: Mock, diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index a8fbe50970ced4..bd2c5cc826d15a 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -404,6 +404,33 @@ async def test_color_hs(hass: HomeAssistant) -> None: assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["hs"] assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": [entity0.entity_id], ATTR_HS_COLOR: (355, 100)}, + blocking=True, + ) + + await hass.async_block_till_done() + state = hass.states.get("light.light_group") + assert state.attributes[ATTR_COLOR_MODE] == "hs" + assert state.attributes[ATTR_HS_COLOR] == (357.5, 75) + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["hs"] + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": [entity1.entity_id], ATTR_HS_COLOR: (5, 90)}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get("light.light_group") + assert state.attributes[ATTR_COLOR_MODE] == "hs" + assert state.attributes[ATTR_HS_COLOR] == (360, 95) + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["hs"] + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + async def test_color_rgb(hass: HomeAssistant) -> None: """Test rgbw color reporting.""" diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index 331d2ccf36a527..365f5023ccc5f3 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -30,6 +30,7 @@ import pytest from homeassistant.components.habitica.const import CONF_API_USER, DEFAULT_URL, DOMAIN +from homeassistant.config_entries import ConfigSubentryData from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant @@ -59,6 +60,30 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture(name="config_entry_with_subentry") +def mock_config_entry_with_subentry() -> MockConfigEntry: + """Mock Habitica configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="test-user", + data={ + CONF_URL: DEFAULT_URL, + CONF_API_USER: "a380546a-94be-4b8e-8a0b-23e0d5c03303", + CONF_API_KEY: "cd0e5985-17de-4b4f-849e-5d506c5e4382", + }, + unique_id="a380546a-94be-4b8e-8a0b-23e0d5c03303", + subentries_data=[ + ConfigSubentryData( + data={}, + subentry_id="ABCDEF", + subentry_type="party_member", + title="test-partymember-displayname", + unique_id="ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + ) + ], + ) + + @pytest.fixture async def set_tz(hass: HomeAssistant) -> None: """Fixture to set timezone.""" diff --git a/tests/components/habitica/fixtures/content.json b/tests/components/habitica/fixtures/content.json index 7e2017d1683c94..cb8550595e75cb 100644 --- a/tests/components/habitica/fixtures/content.json +++ b/tests/components/habitica/fixtures/content.json @@ -85,6 +85,48 @@ } }, "flat": { + "armor_base_0": { + "text": "Plain Clothing", + "notes": "Ordinary clothing. Confers no benefit.", + "value": 0, + "type": "armor", + "key": "armor_base_0", + "set": "base-0", + "klass": "base", + "index": "0", + "str": 0, + "int": 0, + "per": 0, + "con": 0 + }, + "head_base_0": { + "text": "No Headgear", + "notes": "No headgear.", + "value": 0, + "type": "head", + "key": "head_base_0", + "set": "base-0", + "klass": "base", + "index": "0", + "str": 0, + "int": 0, + "per": 0, + "con": 0 + }, + "shield_base_0": { + "text": "No Off-Hand Equipment", + "notes": "No shield or other off-hand item.", + "value": 0, + "type": "shield", + "key": "shield_base_0", + "set": "base-0", + "klass": "base", + "index": "0", + "str": 0, + "int": 0, + "per": 0, + "con": 0 + }, "weapon_warrior_5": { "text": "Ruby Sword", "notes": "Weapon whose forge-glow never fades. Increases Strength by 15. ", diff --git a/tests/components/habitica/fixtures/party_members.json b/tests/components/habitica/fixtures/party_members.json index e1bb31e6d81d97..5ccfa25b533089 100644 --- a/tests/components/habitica/fixtures/party_members.json +++ b/tests/components/habitica/fixtures/party_members.json @@ -157,7 +157,7 @@ }, "order": "level", "orderAscending": "ascending", - "_id": "94cd398c-2240-4320-956e-6d345cf2c0de" + "_id": "1e87097c-4c03-4f8c-a475-67cc7da7f409" }, "preferences": { "size": "slim", @@ -361,7 +361,7 @@ }, "order": "level", "orderAscending": "ascending", - "_id": "94cd398c-2240-4320-956e-6d345cf2c0de" + "_id": "1e87097c-4c03-4f8c-a475-67cc7da7f409" }, "preferences": { "size": "slim", diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index 07ce6488914513..5b01a2f4f30014 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -1,4 +1,701 @@ # serializer version: 1 +# name: test_sensors[sensor.test_partymember_displayname_class-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'warrior', + 'rogue', + 'wizard', + 'healer', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_partymember_displayname_class', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Class', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7_class', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_class-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiAgdmlld0JveD0iLTEyIC0xMiA3MiA3MiI+CiAgICA8ZGVmcz4KICAgICAgICA8cGF0aCBpZD0iYSIgZD0iTTMxLjM5MiA5LjExNWwtNi43OTEgNy4xMjR2Ni4zMTRsNi43OTEtNi43OTFWOS4xMTV6Ii8+CiAgICA8L2RlZnM+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAgMSkiPgogICAgICAgIDxwYXRoIGZpbGw9IiNGMDYxNjYiIGQ9Ik0xOC4zOSAyOC43NjJsNi4zMzQgMi45MmMuNTI2LjI0My44NjguNzMyLjk5OSAxLjI5Ny4yMTkuOTQ4Ljg4MyAzLjE1Ni45MzcgNC4zM2EuODguODggMCAwIDEtMS4yNC44NDRsLTEwLjU4OC01LjA3YTEuNzgxIDEuNzgxIDAgMCAxLS43NjItLjc2M0w5IDIxLjczM2EuODguODggMCAwIDEgLjg0NC0xLjI0YzEuMTczLjA1NCAzLjMzNS42ODMgNC4zMy45MzcuNTYuMTQyIDEuMDU0LjQ3MiAxLjI5Ni45OThsMi45MiA2LjMzNHoiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQzgyQjJCIiBkPSJNMy4wNzMgNDQuMDg1bDIuNzMyIDIuNzMzIDcuMDg1LS41NTcuMzY0LTQuNjU2IDQuMDY5LTQuMDY3IDcuNDAxIDMuNTIyIDUuNTM2LTEuNDktMi4zMjItOS45OSAxNS41MTQtMTQuNDc3TDQ2LjQyMi43NDFsLS4wMDcuMDAyLjAwMy0uMDAzVi43MzRMMzIuMDU3IDMuNzAyIDE3LjU4IDE5LjIxOGwtOS45OS0yLjMyMkw2LjEgMjIuNDNsMy41MjIgNy40MDNMNS41NTUgMzMuOWwtNC42NTQuMzY3LS41NTkgNy4wODQgMi43MzIgMi43MzN6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0YwNjE2NiIgZD0iTTguOTI3IDQxLjc2OWwtMy41NDQtMy41NDQgOC43NjctOC43NjUgMy41NDIgMy41NDR6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0YwNjE2NiIgZD0iTTMuMzc3IDQwLjIzMWwuMjU1LTMuMjM1IDMuMjM2LS4yNTUgMy41NDQgMy41NDYtLjI1NSAzLjIzMy0zLjIzNC4yNTh6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGQjZCOCIgZD0iTTMzLjkzNSA2LjUyNmwuMyA2LjM5MSA4LjEwOC04LjEwOGMtLjE1LS4xNC02LjI2LjU2NS04LjQwOCAxLjcxNyIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNGMjdCODYiIGQ9Ik0zMy43ODcgNi42MDJMMTYuNjYgMjQuNTY4bDQuMzkgMS41MzggMTMuMTg2LTEzLjE4OS0uMy02LjM5MmMtLjA0OC4wMjctLjE0OC4wNzctLjE0OC4wNzciLz4KICAgICAgICA8cGF0aCBmaWxsPSIjRjI3Qjg2IiBkPSJNMTYuNjYgMjQuNTY3bC0uMTQ4LjE2NCAxLjg2NCA0LjA0NSAyLjY3LTIuNjctNC4zODYtMS41Mzl6TTQwLjYyNCAxMy4yMmwtNi4zOTItLjMgOC4xMDktOC4xMDhjLjE0LjE1LS41NjUgNi4yNTktMS43MTcgOC40MDgiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjRTU0MTREIiBkPSJNNDAuNTQ4IDEzLjM2OEwyMi41ODIgMzAuNDk2bC0xLjUzOC00LjM5TDM0LjIzMyAxMi45Mmw2LjM5MS4zYTUuMDMyIDUuMDMyIDAgMCAwLS4wNzYuMTQ4Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0U1NDE0RCIgZD0iTTIyLjU4MiAzMC40OTVsLS4xNjMuMTQ4LTQuMDQ1LTEuODY0IDIuNjctMi42NyAxLjUzOCA0LjM4NnoiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjRkZCNkI4IiBkPSJNNi44NjcgMzYuNzQybC0uNzUgMi43NDItMi40ODYtMi40ODd6TTEwLjQwNiA0MC4yOGwtMi43NDIuNzUgMi40ODYgMi40ODZ6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGQjZCOCIgZD0iTTYuODY3IDM2Ljc0MmwzLjU0NiAzLjU0Ni0yLjc0OC43NDEtMS41NDctMS41NDV6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0YyN0I4NiIgZD0iTTMuMzgyIDQwLjIzN2wyLjc0Mi0uNzUtMi40ODYtMi40ODZ6TTYuOTIxIDQzLjc3NmwuNzUtMi43NDIgMi40ODYgMi40ODZ6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0YyN0I4NiIgZD0iTTMuMzgyIDQwLjIzN2wzLjU0NyAzLjU0Ni43NC0yLjc0OC0xLjU0NS0xLjU0N3oiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjRTU0MTREIiBkPSJNMTQuNzAxIDM2LjAxbC0zLjU1Mi0zLjU1MyAyLjE0My0yLjE0IDMuNTUyIDMuNTV6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0YwNjE2NiIgZD0iTTEyLjU2IDM4LjE1MUw5LjAwOCAzNC42bDIuMTQyLTIuMTQgMy41NTIgMy41NXoiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjRTU0MTREIiBkPSJNMTAuNDE4IDQwLjI5M0w2Ljg2NiAzNi43NGwyLjE0Mi0yLjE0IDMuNTUyIDMuNTV6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGQjZCOCIgZD0iTTE4LjM5NSAyOC43NjRsLTIuOTItNi4zMzRhMS42MjkgMS42MjkgMCAwIDAtLjQ2LS41NzYgMi4xMiAyLjEyIDAgMCAwLS44MzYtLjQyMmMtLjk5NS0uMjU0LTMuMTU3LS44ODMtNC4zMy0uOTM3YS44NzEuODcxIDAgMCAwLS43MjcuMzM2bDQuMjU4IDMuNjM0IDMuMTMgNi4xOTIgMS44OS0xLjg5LS4wMDUtLjAwM3oiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjRjA2MTY2IiBkPSJNMTMuMzggMjQuNDY2TDkuMTIgMjAuODNhLjg3MS44NzEgMCAwIDAtLjExNy45MDRsNS4wNyAxMC41ODdjLjA4NS4xNjUuMTk3LjMxMy4zMjcuNDQ0bDIuMTA5LTIuMTA4LTMuMTMtNi4xOTJ6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0YyN0I4NiIgZD0iTTI2LjY3NCAzNy4zMTJjLS4wNTQtMS4xNzMtLjY4NC0zLjMzNy0uOTM3LTQuMzNhMi4xMiAyLjEyIDAgMCAwLS40MjMtLjgzNiAxLjYyOSAxLjYyOSAwIDAgMC0uNTc2LS40NmwtNi4zMzQtMi45Mi0uMDAyLS4wMDUtMS44OTEgMS44OTEgNi4xOTIgMy4xMyAzLjYzNSA0LjI1OGEuODc0Ljg3NCAwIDAgMCAuMzM2LS43MjgiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjRTU0MTREIiBkPSJNMjIuNzAzIDMzLjc4MWwzLjYzNCA0LjI1OWEuODcxLjg3MSAwIDAgMS0uOTA0LjExN2wtMTAuNTg3LTUuMDdhMS43NjYgMS43NjYgMCAwIDEtLjQ0NC0uMzI3bDIuMTA5LTIuMTA4IDYuMTkyIDMuMTN6Ii8+CiAgICAgICAgPG1hc2sgaWQ9ImIiIGZpbGw9IiNmZmYiPgogICAgICAgICAgICA8dXNlIHhsaW5rOmhyZWY9IiNhIi8+CiAgICAgICAgPC9tYXNrPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkI2QjgiIGQ9Ik0yOS40NzIgMjMuMjhoLjk4N1Y1LjgyOGgtLjk4N3pNMjQuNjAxIDI3LjY5MmgyLjkyVjEwLjI0aC0yLjkyeiIgbWFzaz0idXJsKCNiKSIvPgogICAgPC9nPgo8L3N2Zz4=', + 'friendly_name': 'test-partymember-displayname Class', + 'options': list([ + 'warrior', + 'rogue', + 'wizard', + 'healer', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_partymember_displayname_class', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'warrior', + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_constitution-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_partymember_displayname_constitution', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Constitution', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7_constitution', + 'unit_of_measurement': 'CON', + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_constitution-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'allocated': 0, + 'buffs': 1, + 'class': 0, + 'equipment': 0, + 'friendly_name': 'test-partymember-displayname Constitution', + 'level': 0, + 'unit_of_measurement': 'CON', + }), + 'context': , + 'entity_id': 'sensor.test_partymember_displayname_constitution', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_display_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_partymember_displayname_display_name', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Display name', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7_display_name', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_display_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'blurb': None, + 'friendly_name': 'test-partymember-displayname Display name', + 'joined': datetime.date(2024, 10, 10), + 'last_login': datetime.date(2024, 10, 30), + 'total_logins': 1, + 'username': '@test-partymember-username', + }), + 'context': , + 'entity_id': 'sensor.test_partymember_displayname_display_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'test-partymember-displayname', + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_experience-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_partymember_displayname_experience', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Experience', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7_experience', + 'unit_of_measurement': 'XP', + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_experience-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkE2MjMiIGQ9Ik0xNiAxNmw4LTQtOC00LTQtOC00IDgtOCA0IDggNCA0IDh6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNSAxMmw1LTIuNUwxMiAxMnpNMTIgMTkuNWwtMi41LTVMMTIgMTJ6TTE5LjUgMTJsLTUgMi41TDEyIDEyek0xMiA0LjVsMi41IDVMMTIgMTJ6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQkY3RDFBIiBkPSJNMTkuNSAxMmwtNS0yLjVMMTIgMTJ6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQkY3RDFBIiBkPSJNMTIgMTkuNWwyLjUtNUwxMiAxMnoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNSAxMmw1IDIuNUwxMiAxMnpNMTIgNC41bC0yLjUgNUwxMiAxMnoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEwLjggMTMuMkw4LjUgMTJsMi4zLTEuMkwxMiA4LjVsMS4yIDIuMyAyLjMgMS4yLTIuMyAxLjItMS4yIDIuM3oiIG9wYWNpdHk9Ii41Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', + 'friendly_name': 'test-partymember-displayname Experience', + 'unit_of_measurement': 'XP', + }), + 'context': , + 'entity_id': 'sensor.test_partymember_displayname_experience', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24', + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_health-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_partymember_displayname_health', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Health', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7_health', + 'unit_of_measurement': 'HP', + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_health-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiNGNzRFNTIiIGQ9Ik0yIDQuNUw2LjE2NyAyIDEyIDUuMTY3IDE3LjgzMyAyIDIyIDQuNVYxMmwtNC4xNjcgNS44MzNMMTIgMjJsLTUuODMzLTQuMTY3TDIgMTJ6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGNjE2NSIgZD0iTTcuMzMzIDE2LjY2N0wzLjY2NyAxMS41VjUuNDE3bDIuNS0xLjVMMTIgNy4wODNsNS44MzMtMy4xNjYgMi41IDEuNVYxMS41bC0zLjY2NiA1LjE2N0wxMiAxOS45MTd6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M2w0LjY2NyAyLjU4NEwxMiAxOS45MTd6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNsLTQuNjY3IDIuNTg0TDEyIDE5LjkxN3oiIG9wYWNpdHk9Ii4zNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik03LjMzMyAxNi42NjdMMy42NjcgMTEuNSAxMiAxNC4wODN6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQjUyNDI4IiBkPSJNMTYuNjY3IDE2LjY2N2wzLjY2Ni01LjE2N0wxMiAxNC4wODN6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNsNS44MzMtMTAuMTY2IDIuNSAxLjVWMTEuNXoiIG9wYWNpdHk9Ii4zNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNMNi4xNjcgMy45MTdsLTIuNSAxLjVWMTEuNXoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M0w2LjE2NyAzLjkxNyAxMiA3LjA4M3oiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M2w1LjgzMy0xMC4xNjZMMTIgNy4wODN6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjRkZGIiBkPSJNOS4xNjcgMTQuODMzbC0zLTQuMTY2VjYuODMzaC4wODNMMTIgOS45MTdsNS43NS0zLjA4NGguMDgzdjMuODM0bC0zIDQuMTY2TDEyIDE2LjkxN3oiIG9wYWNpdHk9Ii41Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', + 'friendly_name': 'test-partymember-displayname Health', + 'unit_of_measurement': 'HP', + }), + 'context': , + 'entity_id': 'sensor.test_partymember_displayname_health', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_intelligence-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_partymember_displayname_intelligence', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Intelligence', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7_intelligence', + 'unit_of_measurement': 'INT', + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_intelligence-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'allocated': 0, + 'buffs': 1, + 'class': 0, + 'equipment': 0, + 'friendly_name': 'test-partymember-displayname Intelligence', + 'level': 0, + 'unit_of_measurement': 'INT', + }), + 'context': , + 'entity_id': 'sensor.test_partymember_displayname_intelligence', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_last_check_in-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_partymember_displayname_last_check_in', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last check-in', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7_last_checkin', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_last_check_in-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'test-partymember-displayname Last check-in', + }), + 'context': , + 'entity_id': 'sensor.test_partymember_displayname_last_check_in', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-10-30T19:37:01+00:00', + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_partymember_displayname_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Level', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'test-partymember-displayname Level', + }), + 'context': , + 'entity_id': 'sensor.test_partymember_displayname_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_mana-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_partymember_displayname_mana', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mana', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7_mana', + 'unit_of_measurement': 'MP', + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_mana-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiMyOTk1Q0QiIGQ9Ik0yMiAxNWwtMTAgOS0xMC05TDEyIDB6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iIzUwQjVFOSIgZD0iTTQuNiAxNC43bDcuNC0zdjkuNnoiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjMUY3MDlBIiBkPSJNMTIgMTEuN2w3LjQgMy03LjQgNi42eiIgb3BhY2l0eT0iLjI1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDExLjdWMy42bDcuNCAxMS4xeiIgb3BhY2l0eT0iLjI1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNiAxNC43TDEyIDMuNnY4LjF6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik03LjIgMTQuM0wxMiA3LjJsNC44IDcuMS00LjggNC4zeiIgb3BhY2l0eT0iLjUiLz4KICAgIDwvZz4KPC9zdmc+', + 'friendly_name': 'test-partymember-displayname Mana', + 'unit_of_measurement': 'MP', + }), + 'context': , + 'entity_id': 'sensor.test_partymember_displayname_mana', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.0', + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_max_mana-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_partymember_displayname_max_mana', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Max. mana', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7_mana_max', + 'unit_of_measurement': 'MP', + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_max_mana-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiMyOTk1Q0QiIGQ9Ik0yMiAxNWwtMTAgOS0xMC05TDEyIDB6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iIzUwQjVFOSIgZD0iTTQuNiAxNC43bDcuNC0zdjkuNnoiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjMUY3MDlBIiBkPSJNMTIgMTEuN2w3LjQgMy03LjQgNi42eiIgb3BhY2l0eT0iLjI1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDExLjdWMy42bDcuNCAxMS4xeiIgb3BhY2l0eT0iLjI1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNiAxNC43TDEyIDMuNnY4LjF6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik03LjIgMTQuM0wxMiA3LjJsNC44IDcuMS00LjggNC4zeiIgb3BhY2l0eT0iLjUiLz4KICAgIDwvZz4KPC9zdmc+', + 'friendly_name': 'test-partymember-displayname Max. mana', + 'unit_of_measurement': 'MP', + }), + 'context': , + 'entity_id': 'sensor.test_partymember_displayname_max_mana', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_next_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_partymember_displayname_next_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Next level', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7_experience_max', + 'unit_of_measurement': 'XP', + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_next_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkE2MjMiIGQ9Ik0xNiAxNmw4LTQtOC00LTQtOC00IDgtOCA0IDggNCA0IDh6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNSAxMmw1LTIuNUwxMiAxMnpNMTIgMTkuNWwtMi41LTVMMTIgMTJ6TTE5LjUgMTJsLTUgMi41TDEyIDEyek0xMiA0LjVsMi41IDVMMTIgMTJ6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQkY3RDFBIiBkPSJNMTkuNSAxMmwtNS0yLjVMMTIgMTJ6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQkY3RDFBIiBkPSJNMTIgMTkuNWwyLjUtNUwxMiAxMnoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNSAxMmw1IDIuNUwxMiAxMnpNMTIgNC41bC0yLjUgNUwxMiAxMnoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEwLjggMTMuMkw4LjUgMTJsMi4zLTEuMkwxMiA4LjVsMS4yIDIuMyAyLjMgMS4yLTIuMyAxLjItMS4yIDIuM3oiIG9wYWNpdHk9Ii41Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', + 'friendly_name': 'test-partymember-displayname Next level', + 'unit_of_measurement': 'XP', + }), + 'context': , + 'entity_id': 'sensor.test_partymember_displayname_next_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25', + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_perception-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_partymember_displayname_perception', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Perception', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7_perception', + 'unit_of_measurement': 'PER', + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_perception-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'allocated': 0, + 'buffs': 1, + 'class': 0, + 'equipment': 0, + 'friendly_name': 'test-partymember-displayname Perception', + 'level': 0, + 'unit_of_measurement': 'PER', + }), + 'context': , + 'entity_id': 'sensor.test_partymember_displayname_perception', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_partymember_displayname_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Strength', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7_strength', + 'unit_of_measurement': 'STR', + }) +# --- +# name: test_sensors[sensor.test_partymember_displayname_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'allocated': 0, + 'buffs': 1, + 'class': 0, + 'equipment': 0, + 'friendly_name': 'test-partymember-displayname Strength', + 'level': 0, + 'unit_of_measurement': 'STR', + }), + 'context': , + 'entity_id': 'sensor.test_partymember_displayname_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- # name: test_sensors[sensor.test_user_class-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -163,6 +860,7 @@ 'joined': datetime.date(2013, 12, 2), 'last_login': datetime.date(2025, 2, 1), 'total_logins': 241, + 'username': '@test-username', }), 'context': , 'entity_id': 'sensor.test_user_display_name', diff --git a/tests/components/habitica/test_config_flow.py b/tests/components/habitica/test_config_flow.py index a393c7a60824a9..da690ea560ac5c 100644 --- a/tests/components/habitica/test_config_flow.py +++ b/tests/components/habitica/test_config_flow.py @@ -3,17 +3,23 @@ from typing import Any from unittest.mock import AsyncMock +from habiticalib import HabiticaUserResponse import pytest from homeassistant.components.habitica.const import ( CONF_API_USER, + CONF_PARTY_MEMBER, DEFAULT_URL, DOMAIN, SECTION_DANGER_ZONE, SECTION_REAUTH_API_KEY, SECTION_REAUTH_LOGIN, ) -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import ( + SOURCE_USER, + ConfigEntryDisabler, + ConfigSubentry, +) from homeassistant.const import ( CONF_API_KEY, CONF_PASSWORD, @@ -26,7 +32,7 @@ from .conftest import ERROR_BAD_REQUEST, ERROR_NOT_AUTHORIZED -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_load_fixture TEST_API_USER = "a380546a-94be-4b8e-8a0b-23e0d5c03303" TEST_API_KEY = "cd0e5985-17de-4b4f-849e-5d506c5e4382" @@ -512,3 +518,140 @@ async def test_flow_reconfigure_errors( assert config_entry.data[CONF_VERIFY_SSL] is True assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("habitica") +async def test_add_party_member_flow( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test add party member subentry flow.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "party_member"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_PARTY_MEMBER: "ffce870c-3ff3-4fa4-bad1-87612e52b8e7"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + subentry_id = list(config_entry.subentries)[0] + assert config_entry.subentries == { + subentry_id: ConfigSubentry( + data={}, + subentry_id=subentry_id, + subentry_type="party_member", + title="test-partymember-displayname", + unique_id="ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + ) + } + + +@pytest.mark.usefixtures("habitica") +async def test_add_party_member_already_configured( + hass: HomeAssistant, + config_entry_with_subentry: MockConfigEntry, +) -> None: + """Test add party member subentry flow abort when already configured.""" + + config_entry_with_subentry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry_with_subentry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (config_entry_with_subentry.entry_id, "party_member"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_PARTY_MEMBER: "ffce870c-3ff3-4fa4-bad1-87612e52b8e7"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("habitica") +async def test_add_party_member_already_configured_as_entry( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test add party member subentry flow abort when already configured entry.""" + + MockConfigEntry( + domain=DOMAIN, + unique_id="ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + ).add_to_hass(hass) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "party_member"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_PARTY_MEMBER: "ffce870c-3ff3-4fa4-bad1-87612e52b8e7"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_as_entry" + + +async def test_add_party_member_not_in_a_party( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, +) -> None: + """Test add party member subentry flow abort when user is not in a party.""" + habitica.get_user.return_value = HabiticaUserResponse.from_json( + await async_load_fixture(hass, "user_no_party.json", DOMAIN) + ) + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "party_member"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "not_in_a_party" + + +@pytest.mark.usefixtures("habitica") +async def test_add_party_member_entry_disabled(hass: HomeAssistant) -> None: + """Test we abort add party member subentry flow when the main config entry is disabled.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + disabled_by=ConfigEntryDisabler.USER, + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "party_member"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "config_entry_disabled" diff --git a/tests/components/habitica/test_sensor.py b/tests/components/habitica/test_sensor.py index 9dde266d214545..46f59d45a8333f 100644 --- a/tests/components/habitica/test_sensor.py +++ b/tests/components/habitica/test_sensor.py @@ -27,16 +27,18 @@ def sensor_only() -> Generator[None]: @pytest.mark.usefixtures("habitica", "entity_registry_enabled_by_default") async def test_sensors( hass: HomeAssistant, - config_entry: MockConfigEntry, + config_entry_with_subentry: MockConfigEntry, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, ) -> None: """Test setup of the Habitica sensor platform.""" - config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) + config_entry_with_subentry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_with_subentry.entry_id) await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED + assert config_entry_with_subentry.state is ConfigEntryState.LOADED - await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + await snapshot_platform( + hass, entity_registry, snapshot, config_entry_with_subentry.entry_id + ) diff --git a/tests/components/togrill/test_config_flow.py b/tests/components/togrill/test_config_flow.py index 2620a88f7f29b3..618f82ccd9ea21 100644 --- a/tests/components/togrill/test_config_flow.py +++ b/tests/components/togrill/test_config_flow.py @@ -47,6 +47,32 @@ async def test_user_selection( assert result["result"].unique_id == TOGRILL_SERVICE_INFO.address +async def test_user_selection_ignored( + hass: HomeAssistant, +) -> None: + """Test we can select a device.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TOGRILL_SERVICE_INFO.address, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + await hass.async_block_till_done(wait_background_tasks=True) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": TOGRILL_SERVICE_INFO.address}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + async def test_failed_connect( hass: HomeAssistant, mock_client: Mock, diff --git a/tests/components/xbox/conftest.py b/tests/components/xbox/conftest.py index 090d8d49f2f536..e6ad579e0c2d21 100644 --- a/tests/components/xbox/conftest.py +++ b/tests/components/xbox/conftest.py @@ -65,17 +65,36 @@ def mock_config_entry() -> MockConfigEntry: "user_id": "AAAAAAAAAAAAAAAAAAAAA", }, }, - unique_id="xbox", ) +@pytest.fixture(name="authentication_manager") +def mock_authentication_manager() -> Generator[AsyncMock]: + """Mock xbox-webapi AuthenticationManager.""" + + with ( + patch( + "homeassistant.components.xbox.config_flow.AuthenticationManager", + autospec=True, + ) as mock_client, + ): + client = mock_client.return_value + + yield client + + @pytest.fixture(name="signed_session") def mock_signed_session() -> Generator[AsyncMock]: """Mock xbox-webapi SignedSession.""" - with patch( - "homeassistant.components.xbox.SignedSession", autospec=True - ) as mock_client: + with ( + patch( + "homeassistant.components.xbox.SignedSession", autospec=True + ) as mock_client, + patch( + "homeassistant.components.xbox.config_flow.SignedSession", new=mock_client + ), + ): client = mock_client.return_value yield client @@ -85,9 +104,14 @@ def mock_signed_session() -> Generator[AsyncMock]: def mock_xbox_live_client(signed_session) -> Generator[AsyncMock]: """Mock xbox-webapi XboxLiveClient.""" - with patch( - "homeassistant.components.xbox.XboxLiveClient", autospec=True - ) as mock_client: + with ( + patch( + "homeassistant.components.xbox.XboxLiveClient", autospec=True + ) as mock_client, + patch( + "homeassistant.components.xbox.config_flow.XboxLiveClient", new=mock_client + ), + ): client = mock_client.return_value client.smartglass = AsyncMock() @@ -110,4 +134,7 @@ def mock_xbox_live_client(signed_session) -> Generator[AsyncMock]: client.people.get_friends_own.return_value = PeopleResponse( **load_json_object_fixture("people_friends_own.json", DOMAIN) ) + + client.xuid = "271958441785640" + yield client diff --git a/tests/components/xbox/test_config_flow.py b/tests/components/xbox/test_config_flow.py index 533b2359ad3c02..66c92d7e807047 100644 --- a/tests/components/xbox/test_config_flow.py +++ b/tests/components/xbox/test_config_flow.py @@ -29,7 +29,11 @@ async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: assert result["reason"] == "single_instance_allowed" -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures( + "current_request_with_host", + "xbox_live_client", + "authentication_manager", +) async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -68,13 +72,57 @@ async def test_full_flow( "access_token": "mock-access-token", "type": "Bearer", "expires_in": 60, + "scope": "XboxLive.signin XboxLive.offline_access", + "service": "xbox", + "token_type": "bearer", + "user_id": "AAAAAAAAAAAAAAAAAAAAA", }, ) with patch( "homeassistant.components.xbox.async_setup_entry", return_value=True ) as mock_setup: - await hass.config_entries.flow.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["result"].unique_id == "271958441785640" + assert result["result"].title == "GSR Ae" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup.mock_calls) == 1 + + +@pytest.mark.usefixtures("xbox_live_client") +async def test_unique_id_migration( + hass: HomeAssistant, +) -> None: + """Test config entry unique_id migration.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="Home Assistant Cloud", + data={ + "auth_implementation": "cloud", + "token": { + "access_token": "1234567890", + "expires_at": 1760697327.7298331, + "expires_in": 3600, + "refresh_token": "0987654321", + "scope": "XboxLive.signin XboxLive.offline_access", + "service": "xbox", + "token_type": "bearer", + "user_id": "AAAAAAAAAAAAAAAAAAAAA", + }, + }, + unique_id=DOMAIN, + version=1, + minor_version=1, + ) + + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is config_entries.ConfigEntryState.LOADED + assert config_entry.version == 1 + assert config_entry.minor_version == 2 + assert config_entry.unique_id == "271958441785640" + assert config_entry.title == "GSR Ae"