From 6fa7c6cb81f50062caffcbb200a7aefc84952b16 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 12 Aug 2025 20:51:12 +0200 Subject: [PATCH] Add party to Habitica (#149608) --- homeassistant/components/habitica/__init__.py | 61 ++- .../components/habitica/binary_sensor.py | 48 ++- .../components/habitica/coordinator.py | 87 +++-- homeassistant/components/habitica/entity.py | 37 +- homeassistant/components/habitica/icons.json | 21 ++ homeassistant/components/habitica/image.py | 81 +++- .../components/habitica/quality_scale.yaml | 2 +- homeassistant/components/habitica/sensor.py | 135 ++++++- .../components/habitica/strings.json | 42 ++- homeassistant/components/habitica/util.py | 33 +- tests/components/habitica/conftest.py | 4 + tests/components/habitica/fixtures/party.json | 75 ++++ tests/components/habitica/fixtures/user.json | 2 +- .../habitica/fixtures/user_no_party.json | 4 +- .../snapshots/test_binary_sensor.ambr | 49 +++ .../habitica/snapshots/test_sensor.ambr | 354 ++++++++++++++++++ tests/components/habitica/test_init.py | 42 ++- 17 files changed, 1023 insertions(+), 54 deletions(-) create mode 100644 tests/components/habitica/fixtures/party.json diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index 217b5e739d1..514a12d26b7 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -1,19 +1,26 @@ """The habitica integration.""" +from uuid import UUID + from habiticalib import Habitica from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey from .const import CONF_API_USER, DOMAIN, X_CLIENT -from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator +from .coordinator import ( + HabiticaConfigEntry, + HabiticaDataUpdateCoordinator, + HabiticaPartyCoordinator, +) from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) - +HABITICA_KEY: HassKey[dict[UUID, HabiticaPartyCoordinator]] = HassKey(DOMAIN) PLATFORMS = [ Platform.BINARY_SENSOR, @@ -37,6 +44,8 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: HabiticaConfigEntry ) -> bool: """Set up habitica from a config entry.""" + party_added_by_this_entry: UUID | None = None + device_reg = dr.async_get(hass) session = async_get_clientsession( hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True) @@ -54,11 +63,53 @@ async def async_setup_entry( await coordinator.async_config_entry_first_refresh() config_entry.runtime_data = coordinator - await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + party = coordinator.data.user.party.id + if HABITICA_KEY not in hass.data: + hass.data[HABITICA_KEY] = {} + + if party is not None and party not in hass.data[HABITICA_KEY]: + party_coordinator = HabiticaPartyCoordinator(hass, config_entry, api) + await party_coordinator.async_config_entry_first_refresh() + + hass.data[HABITICA_KEY][party] = party_coordinator + party_added_by_this_entry = party + + @callback + def _party_update_listener() -> None: + """On party change, unload coordinator, remove device and reload.""" + nonlocal party, party_added_by_this_entry + party_updated = coordinator.data.user.party.id + + if ( + party is not None and (party not in hass.data[HABITICA_KEY]) + ) or party != party_updated: + if party_added_by_this_entry: + config_entry.async_create_task( + hass, shutdown_party_coordinator(hass, party_added_by_this_entry) + ) + party_added_by_this_entry = None + if party: + identifier = {(DOMAIN, f"{config_entry.unique_id}_{party!s}")} + if device := device_reg.async_get_device(identifiers=identifier): + device_reg.async_update_device( + device.id, remove_config_entry_id=config_entry.entry_id + ) + + hass.config_entries.async_schedule_reload(config_entry.entry_id) + + coordinator.async_add_listener(_party_update_listener) + + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True +async def shutdown_party_coordinator(hass: HomeAssistant, party_added: UUID) -> None: + """Handle party coordinator shutdown.""" + await hass.data[HABITICA_KEY][party_added].async_shutdown() + hass.data[HABITICA_KEY].pop(party_added) + + async def async_unload_entry(hass: HomeAssistant, entry: HabiticaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/habitica/binary_sensor.py b/homeassistant/components/habitica/binary_sensor.py index c6f7ee0fb83..621c659a10c 100644 --- a/homeassistant/components/habitica/binary_sensor.py +++ b/homeassistant/components/habitica/binary_sensor.py @@ -6,18 +6,20 @@ from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum -from habiticalib import UserData +from habiticalib import ContentData, UserData from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import HABITICA_KEY from .const import ASSETS_URL -from .coordinator import HabiticaConfigEntry -from .entity import HabiticaBase +from .coordinator import HabiticaConfigEntry, HabiticaPartyCoordinator +from .entity import HabiticaBase, HabiticaPartyBase PARALLEL_UPDATES = 1 @@ -34,6 +36,7 @@ class HabiticaBinarySensor(StrEnum): """Habitica Entities.""" PENDING_QUEST = "pending_quest" + QUEST_RUNNING = "quest_running" def get_scroll_image_for_pending_quest_invitation(user: UserData) -> str | None: @@ -62,10 +65,21 @@ async def async_setup_entry( coordinator = config_entry.runtime_data - async_add_entities( + entities: list[BinarySensorEntity] = [ HabiticaBinarySensorEntity(coordinator, description) for description in BINARY_SENSOR_DESCRIPTIONS - ) + ] + + if party := coordinator.data.user.party.id: + party_coordinator = hass.data[HABITICA_KEY][party] + entities.append( + HabiticaPartyBinarySensorEntity( + party_coordinator, + config_entry, + coordinator.content, + ) + ) + async_add_entities(entities) class HabiticaBinarySensorEntity(HabiticaBase, BinarySensorEntity): @@ -86,3 +100,27 @@ class HabiticaBinarySensorEntity(HabiticaBase, BinarySensorEntity): ): return f"{ASSETS_URL}{entity_picture}" return None + + +class HabiticaPartyBinarySensorEntity(HabiticaPartyBase, BinarySensorEntity): + """Representation of a Habitica party binary sensor.""" + + entity_description = BinarySensorEntityDescription( + key=HabiticaBinarySensor.QUEST_RUNNING, + translation_key=HabiticaBinarySensor.QUEST_RUNNING, + device_class=BinarySensorDeviceClass.RUNNING, + ) + + def __init__( + self, + coordinator: HabiticaPartyCoordinator, + config_entry: HabiticaConfigEntry, + content: ContentData, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator, config_entry, self.entity_description, content) + + @property + def is_on(self) -> bool | None: + """If the binary sensor is on.""" + return self.coordinator.data.quest.active diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index 0e0a2db8d58..d9376820b16 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +from abc import abstractmethod from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta @@ -13,6 +14,7 @@ from aiohttp import ClientError from habiticalib import ( Avatar, ContentData, + GroupData, Habitica, HabiticaException, NotAuthorizedError, @@ -49,10 +51,11 @@ class HabiticaData: type HabiticaConfigEntry = ConfigEntry[HabiticaDataUpdateCoordinator] -class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): - """Habitica Data Update Coordinator.""" +class HabiticaBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """Habitica coordinator base class.""" config_entry: HabiticaConfigEntry + _update_interval: timedelta def __init__( self, hass: HomeAssistant, config_entry: HabiticaConfigEntry, habitica: Habitica @@ -63,7 +66,7 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=timedelta(seconds=60), + update_interval=self._update_interval, request_refresh_debouncer=Debouncer( hass, _LOGGER, @@ -71,8 +74,40 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): immediate=False, ), ) + self.habitica = habitica - self.content: ContentData + + @abstractmethod + async def _update_data(self) -> _DataT: + """Fetch data.""" + + async def _async_update_data(self) -> _DataT: + """Fetch the latest party data.""" + + try: + return await self._update_data() + except TooManyRequestsError: + _LOGGER.debug("Rate limit exceeded, will try again later") + return self.data + except HabiticaException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + + +class HabiticaDataUpdateCoordinator(HabiticaBaseCoordinator[HabiticaData]): + """Habitica Data Update Coordinator.""" + + _update_interval = timedelta(seconds=30) + content: ContentData async def _async_setup(self) -> None: """Set up Habitica integration.""" @@ -106,30 +141,16 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): translation_placeholders={"reason": str(e)}, ) from e - async def _async_update_data(self) -> HabiticaData: - try: - user = (await self.habitica.get_user()).data - tasks = (await self.habitica.get_tasks()).data - completed_todos = ( - await self.habitica.get_tasks(TaskFilter.COMPLETED_TODOS) - ).data - except TooManyRequestsError: - _LOGGER.debug("Rate limit exceeded, will try again later") - return self.data - except HabiticaException as e: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e.error.message)}, - ) from e - except ClientError as e: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e)}, - ) from e - else: - return HabiticaData(user=user, tasks=tasks + completed_todos) + async def _update_data(self) -> HabiticaData: + """Fetch the latest data.""" + + user = (await self.habitica.get_user()).data + tasks = (await self.habitica.get_tasks()).data + completed_todos = ( + await self.habitica.get_tasks(TaskFilter.COMPLETED_TODOS) + ).data + + return HabiticaData(user=user, tasks=tasks + completed_todos) async def execute(self, func: Callable[[Habitica], Any]) -> None: """Execute an API call.""" @@ -169,3 +190,13 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): await self.habitica.generate_avatar(fp=png, avatar=avatar, fmt="PNG") return png.getvalue() + + +class HabiticaPartyCoordinator(HabiticaBaseCoordinator[GroupData]): + """Habitica Party Coordinator.""" + + _update_interval = timedelta(minutes=15) + + async def _update_data(self) -> GroupData: + """Fetch the latest party data.""" + return (await self.habitica.get_group()).data diff --git a/homeassistant/components/habitica/entity.py b/homeassistant/components/habitica/entity.py index 6d320f93517..fa227fec334 100644 --- a/homeassistant/components/habitica/entity.py +++ b/homeassistant/components/habitica/entity.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import TYPE_CHECKING +from habiticalib import ContentData from yarl import URL from homeassistant.const import CONF_URL @@ -12,7 +13,11 @@ from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER, NAME -from .coordinator import HabiticaDataUpdateCoordinator +from .coordinator import ( + HabiticaConfigEntry, + HabiticaDataUpdateCoordinator, + HabiticaPartyCoordinator, +) class HabiticaBase(CoordinatorEntity[HabiticaDataUpdateCoordinator]): @@ -45,3 +50,33 @@ class HabiticaBase(CoordinatorEntity[HabiticaDataUpdateCoordinator]): ), identifiers={(DOMAIN, coordinator.config_entry.unique_id)}, ) + + +class HabiticaPartyBase(CoordinatorEntity[HabiticaPartyCoordinator]): + """Base Habitica entity representing a party.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: HabiticaPartyCoordinator, + config_entry: HabiticaConfigEntry, + entity_description: EntityDescription, + content: ContentData, + ) -> None: + """Initialize a Habitica party entity.""" + super().__init__(coordinator) + if TYPE_CHECKING: + assert config_entry.unique_id + unique_id = f"{config_entry.unique_id}_{coordinator.data.id!s}" + self.entity_description = entity_description + 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.summary, + identifiers={(DOMAIN, unique_id)}, + via_device=(DOMAIN, config_entry.unique_id), + ) + self.content = content diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index be25bebe779..0b5d4aaa682 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -156,6 +156,24 @@ }, "pending_quest_items": { "default": "mdi:sack" + }, + "group_leader": { + "default": "mdi:shield-crown" + }, + "quest": { + "default": "mdi:script-text-outline" + }, + "boss": { + "default": "mdi:emoticon-devil" + }, + "boss_hp": { + "default": "mdi:heart" + }, + "boss_hp_remaining": { + "default": "mdi:heart" + }, + "collected_items": { + "default": "mdi:sack" } }, "switch": { @@ -172,6 +190,9 @@ "state": { "on": "mdi:script-text-outline" } + }, + "quest_running": { + "default": "mdi:script-text-play" } } }, diff --git a/homeassistant/components/habitica/image.py b/homeassistant/components/habitica/image.py index 1669f124bc7..f064074ea0a 100644 --- a/homeassistant/components/habitica/image.py +++ b/homeassistant/components/habitica/image.py @@ -4,15 +4,21 @@ from __future__ import annotations from enum import StrEnum -from habiticalib import Avatar, extract_avatar +from habiticalib import Avatar, ContentData, extract_avatar -from homeassistant.components.image import ImageEntity, ImageEntityDescription +from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator -from .entity import HabiticaBase +from . import HABITICA_KEY +from .const import ASSETS_URL +from .coordinator import ( + HabiticaConfigEntry, + HabiticaDataUpdateCoordinator, + HabiticaPartyCoordinator, +) +from .entity import HabiticaBase, HabiticaPartyBase PARALLEL_UPDATES = 1 @@ -21,6 +27,7 @@ class HabiticaImageEntity(StrEnum): """Image entities.""" AVATAR = "avatar" + QUEST_IMAGE = "quest_image" async def async_setup_entry( @@ -31,8 +38,17 @@ async def async_setup_entry( """Set up the habitica image platform.""" coordinator = config_entry.runtime_data + entities: list[ImageEntity] = [HabiticaImage(hass, coordinator)] - async_add_entities([HabiticaImage(hass, coordinator)]) + if party := coordinator.data.user.party.id: + party_coordinator = hass.data[HABITICA_KEY][party] + entities.append( + HabiticaPartyImage( + hass, party_coordinator, config_entry, coordinator.content + ) + ) + + async_add_entities(entities) class HabiticaImage(HabiticaBase, ImageEntity): @@ -72,3 +88,58 @@ class HabiticaImage(HabiticaBase, ImageEntity): if not self._cache and self._avatar: self._cache = await self.coordinator.generate_avatar(self._avatar) return self._cache + + +class HabiticaPartyImage(HabiticaPartyBase, ImageEntity): + """A Habitica image entity of a party.""" + + entity_description = ImageEntityDescription( + key=HabiticaImageEntity.QUEST_IMAGE, + translation_key=HabiticaImageEntity.QUEST_IMAGE, + ) + _attr_content_type = "image/png" + + def __init__( + self, + hass: HomeAssistant, + coordinator: HabiticaPartyCoordinator, + config_entry: HabiticaConfigEntry, + content: ContentData, + ) -> None: + """Initialize the image entity.""" + super().__init__(coordinator, config_entry, self.entity_description, content) + ImageEntity.__init__(self, hass) + + self._attr_image_url = self.image_url + self._attr_image_last_updated = dt_util.utcnow() + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + if self.image_url != self._attr_image_url: + self._attr_image_url = self.image_url + self._cached_image = None + self._attr_image_last_updated = dt_util.utcnow() + + super()._handle_coordinator_update() + + @property + def image_url(self) -> str | None: + """Return URL of image.""" + return ( + f"{ASSETS_URL}quest_{key}.png" + if (key := self.coordinator.data.quest.key) + else None + ) + + async def _async_load_image_from_url(self, url: str) -> Image | None: + """Load an image by url. + + AWS sometimes returns 'application/octet-stream' as content-type + """ + if response := await self._fetch_url(url): + return Image( + content=response.content, + content_type=self._attr_content_type, + ) + return None diff --git a/homeassistant/components/habitica/quality_scale.yaml b/homeassistant/components/habitica/quality_scale.yaml index 1752e67cf46..c5131b81a4d 100644 --- a/homeassistant/components/habitica/quality_scale.yaml +++ b/homeassistant/components/habitica/quality_scale.yaml @@ -72,7 +72,7 @@ rules: comment: Used to inform of deprecated entities and actions. stale-devices: status: done - comment: Not applicable. Only one device per config entry. Removed together with the config entry. + comment: Party device is remove if stale. # Platinum async-dependency: done diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index 6d077495c4f..7a84d589bfb 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -8,7 +8,7 @@ from enum import StrEnum import logging from typing import Any -from habiticalib import ContentData, HabiticaClass, TaskData, UserData, ha +from habiticalib import ContentData, GroupData, HabiticaClass, TaskData, UserData, ha from homeassistant.components.sensor import ( SensorDeviceClass, @@ -20,15 +20,19 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util +from . import HABITICA_KEY from .const import ASSETS_URL from .coordinator import HabiticaConfigEntry -from .entity import HabiticaBase +from .entity import HabiticaBase, HabiticaPartyBase from .util import ( + collected_quest_items, get_attribute_points, get_attributes_total, inventory_list, pending_damage, pending_quest_items, + quest_attributes, + quest_boss, ) _LOGGER = logging.getLogger(__name__) @@ -55,6 +59,17 @@ class HabiticaSensorEntityDescription(SensorEntityDescription): entity_picture: str | None = None +@dataclass(kw_only=True, frozen=True) +class HabiticaPartySensorEntityDescription(SensorEntityDescription): + """Habitica Party Sensor Description.""" + + value_fn: Callable[[GroupData, ContentData], StateType] + entity_picture: Callable[[GroupData], str | None] | str | None = None + attributes_fn: Callable[[GroupData, ContentData], dict[str, Any] | None] | None = ( + None + ) + + @dataclass(kw_only=True, frozen=True) class HabiticaTaskSensorEntityDescription(SensorEntityDescription): """Habitica Task Sensor Description.""" @@ -89,6 +104,13 @@ class HabiticaSensorEntity(StrEnum): QUEST_SCROLLS = "quest_scrolls" PENDING_DAMAGE = "pending_damage" PENDING_QUEST_ITEMS = "pending_quest_items" + MEMBER_COUNT = "member_count" + GROUP_LEADER = "group_leader" + QUEST = "quest" + BOSS = "boss" + BOSS_HP = "boss_hp" + BOSS_HP_REMAINING = "boss_hp_remaining" + COLLECTED_ITEMS = "collected_items" SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( @@ -262,6 +284,67 @@ SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( ) +SENSOR_DESCRIPTIONS_PARTY: tuple[HabiticaPartySensorEntityDescription, ...] = ( + HabiticaPartySensorEntityDescription( + key=HabiticaSensorEntity.MEMBER_COUNT, + translation_key=HabiticaSensorEntity.MEMBER_COUNT, + value_fn=lambda party, _: party.memberCount, + entity_picture=ha.PARTY, + ), + HabiticaPartySensorEntityDescription( + key=HabiticaSensorEntity.GROUP_LEADER, + translation_key=HabiticaSensorEntity.GROUP_LEADER, + value_fn=lambda party, _: party.leader.profile.name, + ), + HabiticaPartySensorEntityDescription( + key=HabiticaSensorEntity.QUEST, + translation_key=HabiticaSensorEntity.QUEST, + value_fn=lambda p, c: c.quests[p.quest.key].text if p.quest.key else None, + attributes_fn=quest_attributes, + entity_picture=( + lambda party: f"inventory_quest_scroll_{party.quest.key}.png" + if party.quest.key + else None + ), + ), + HabiticaPartySensorEntityDescription( + key=HabiticaSensorEntity.BOSS, + translation_key=HabiticaSensorEntity.BOSS, + value_fn=lambda p, c: boss.name if (boss := quest_boss(p, c)) else None, + ), + HabiticaPartySensorEntityDescription( + key=HabiticaSensorEntity.BOSS_HP, + translation_key=HabiticaSensorEntity.BOSS_HP, + value_fn=lambda p, c: boss.hp if (boss := quest_boss(p, c)) else None, + entity_picture=ha.HP, + suggested_display_precision=0, + ), + HabiticaPartySensorEntityDescription( + key=HabiticaSensorEntity.BOSS_HP_REMAINING, + translation_key=HabiticaSensorEntity.BOSS_HP_REMAINING, + value_fn=lambda p, _: p.quest.progress.hp, + entity_picture=ha.HP, + suggested_display_precision=2, + ), + HabiticaPartySensorEntityDescription( + key=HabiticaSensorEntity.COLLECTED_ITEMS, + translation_key=HabiticaSensorEntity.COLLECTED_ITEMS, + value_fn=( + lambda p, _: sum(n for n in p.quest.progress.collect.values()) + if p.quest.progress.collect + else None + ), + attributes_fn=collected_quest_items, + entity_picture=( + lambda p: f"quest_{p.quest.key}_{k}.png" + if p.quest.progress.collect + and (k := next(iter(p.quest.progress.collect), None)) + else None + ), + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: HabiticaConfigEntry, @@ -275,6 +358,18 @@ async def async_setup_entry( HabiticaSensor(coordinator, description) for description in SENSOR_DESCRIPTIONS ) + if party := coordinator.data.user.party.id: + party_coordinator = hass.data[HABITICA_KEY][party] + async_add_entities( + HabiticaPartySensor( + party_coordinator, + config_entry, + description, + coordinator.content, + ) + for description in SENSOR_DESCRIPTIONS_PARTY + ) + class HabiticaSensor(HabiticaBase, SensorEntity): """A generic Habitica sensor.""" @@ -317,3 +412,39 @@ class HabiticaSensor(HabiticaBase, SensorEntity): ) return None + + +class HabiticaPartySensor(HabiticaPartyBase, SensorEntity): + """Habitica party sensor.""" + + entity_description: HabiticaPartySensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the state of the device.""" + + return self.entity_description.value_fn(self.coordinator.data, self.content) + + @property + def entity_picture(self) -> str | None: + """Return the entity picture to use in the frontend, if any.""" + pic = self.entity_description.entity_picture + + entity_picture = ( + pic if isinstance(pic, str) or pic is None else pic(self.coordinator.data) + ) + + return ( + None + if not entity_picture + else entity_picture + if entity_picture.startswith("data:image") + else f"{ASSETS_URL}{entity_picture}" + ) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return entity specific state attributes.""" + if func := self.entity_description.attributes_fn: + return func(self.coordinator.data, self.content) + return None diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 6f0b3dc35cd..1d62b242149 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -7,6 +7,7 @@ "unit_health_points": "HP", "unit_mana_points": "MP", "unit_experience_points": "XP", + "unit_items": "items", "config_entry_description": "Select the Habitica account to update a task.", "task_description": "The name (or task ID) of the task you want to update.", "rename_name": "Rename", @@ -63,7 +64,8 @@ "repeat_weekly_options_name": "Weekly repeat days", "repeat_weekly_options_description": "Options related to weekly repetition, applicable when the repetition interval is set to weekly.", "repeat_monthly_options_name": "Monthly repeat day", - "repeat_monthly_options_description": "Options related to monthly repetition, applicable when the repetition interval is set to monthly." + "repeat_monthly_options_description": "Options related to monthly repetition, applicable when the repetition interval is set to monthly.", + "quest_name": "Quest" }, "config": { "abort": { @@ -173,6 +175,9 @@ "binary_sensor": { "pending_quest": { "name": "Pending quest invitation" + }, + "quest_running": { + "name": "Quest status" } }, "button": { @@ -251,6 +256,9 @@ "image": { "avatar": { "name": "Avatar" + }, + "quest_image": { + "name": "[%key:component::habitica::common::quest_name%]" } }, "sensor": { @@ -420,7 +428,37 @@ }, "pending_quest_items": { "name": "Pending quest items", - "unit_of_measurement": "items" + "unit_of_measurement": "[%key:component::habitica::common::unit_items%]" + }, + "member_count": { + "name": "Member count", + "unit_of_measurement": "members" + }, + "group_leader": { + "name": "Group leader" + }, + "quest": { + "name": "[%key:component::habitica::common::quest_name%]", + "state_attributes": { + "quest_details": { + "name": "Quest details" + } + } + }, + "boss": { + "name": "Quest boss" + }, + "boss_hp": { + "name": "Boss health", + "unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]" + }, + "boss_hp_remaining": { + "name": "Boss health remaining", + "unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]" + }, + "collected_items": { + "name": "Collected quest items", + "unit_of_measurement": "[%key:component::habitica::common::unit_items%]" } }, "switch": { diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index 4f948b9b4d2..8c2148192a3 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -5,7 +5,7 @@ from __future__ import annotations from dataclasses import asdict, fields import datetime from math import floor -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Any, Literal from dateutil.rrule import ( DAILY, @@ -21,7 +21,7 @@ from dateutil.rrule import ( YEARLY, rrule, ) -from habiticalib import ContentData, Frequency, TaskData, UserData +from habiticalib import ContentData, Frequency, GroupData, QuestBoss, TaskData, UserData from homeassistant.util import dt as dt_util @@ -184,3 +184,32 @@ def pending_damage(user: UserData, content: ContentData) -> float | None: and content.quests[user.party.quest.key].boss is not None else None ) + + +def quest_attributes(party: GroupData, content: ContentData) -> dict[str, Any]: + """Quest description.""" + return { + "quest_details": content.quests[party.quest.key].notes + if party.quest.key + else None, + "quest_participants": f"{sum(x is True for x in party.quest.members.values())} / {party.memberCount}", + } + + +def quest_boss(party: GroupData, content: ContentData) -> QuestBoss | None: + """Quest boss.""" + + return content.quests[party.quest.key].boss if party.quest.key else None + + +def collected_quest_items(party: GroupData, content: ContentData) -> dict[str, Any]: + """List collected quest items.""" + + return ( + { + collect[k].text: f"{v} / {collect[k].count}" + for k, v in party.quest.progress.collect.items() + } + if party.quest.key and (collect := content.quests[party.quest.key].collect) + else {} + ) diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index 80e09d823cc..331d2ccf36a 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -10,6 +10,7 @@ from habiticalib import ( HabiticaContentResponse, HabiticaErrorResponse, HabiticaGroupMembersResponse, + HabiticaGroupsResponse, HabiticaLoginResponse, HabiticaQuestResponse, HabiticaResponse, @@ -155,6 +156,9 @@ async def mock_habiticalib(hass: HomeAssistant) -> AsyncGenerator[AsyncMock]: client.create_task.return_value = HabiticaTaskResponse.from_json( await async_load_fixture(hass, "task.json", DOMAIN) ) + client.get_group.return_value = HabiticaGroupsResponse.from_json( + await async_load_fixture(hass, "party.json", DOMAIN) + ) yield client diff --git a/tests/components/habitica/fixtures/party.json b/tests/components/habitica/fixtures/party.json new file mode 100644 index 00000000000..18e7936ca85 --- /dev/null +++ b/tests/components/habitica/fixtures/party.json @@ -0,0 +1,75 @@ +{ + "success": true, + "data": { + "leaderOnly": { + "challenges": false, + "getGems": false + }, + "quest": { + "progress": { + "collect": { + "soapBars": 10 + } + }, + "key": "atom1", + "active": true, + "leader": "d69833ef-4542-4259-ba50-9b4a1a841bcf", + "members": { + "d69833ef-4542-4259-ba50-9b4a1a841bcf": true + }, + "extra": {} + }, + "tasksOrder": { + "habits": [], + "dailys": [], + "todos": [], + "rewards": [] + }, + "purchased": { + "plan": { + "consecutive": { + "count": 0, + "offset": 0, + "gemCapExtra": 0, + "trinkets": 0 + }, + "quantity": 1, + "extraMonths": 0, + "gemsBought": 0, + "cumulativeCount": 0, + "mysteryItems": [] + } + }, + "cron": {}, + "_id": "1e87097c-4c03-4f8c-a475-67cc7da7f409", + "name": "test-user's Party", + "type": "party", + "privacy": "private", + "chat": [], + "memberCount": 2, + "challengeCount": 0, + "balance": 0, + "managers": {}, + "categories": [], + "leader": { + "auth": { + "local": { + "username": "test-username" + } + }, + "flags": { + "verifiedUsername": true + }, + "profile": { + "name": "test-user" + }, + "_id": "af36e2a8-7927-4dec-a258-400ade7f0ae3", + "id": "af36e2a8-7927-4dec-a258-400ade7f0ae3" + }, + "summary": "test-user's Party", + "id": "1e87097c-4c03-4f8c-a475-67cc7da7f409" + }, + "notifications": [], + "userV": 0, + "appVersion": "5.38.0" +} diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index d2f0091b6dd..28faec64dc9 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -76,7 +76,7 @@ "RSVPNeeded": true, "key": "dustbunnies" }, - "_id": "94cd398c-2240-4320-956e-6d345cf2c0de" + "_id": "1e87097c-4c03-4f8c-a475-67cc7da7f409" }, "tags": [ { diff --git a/tests/components/habitica/fixtures/user_no_party.json b/tests/components/habitica/fixtures/user_no_party.json index 1c58dde6f50..bd447b1af67 100644 --- a/tests/components/habitica/fixtures/user_no_party.json +++ b/tests/components/habitica/fixtures/user_no_party.json @@ -55,7 +55,9 @@ "e97659e0-2c42-4599-a7bb-00282adc410d", "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", "f2c85972-1a19-4426-bc6d-ce3337b9d99f", - "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1" + "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1", + "6e53f1f5-a315-4edd-984d-8d762e4a08ef", + "369afeed-61e3-4bf7-9747-66e05807134c" ], "habits": ["1d147de6-5c02-4740-8e2f-71d3015a37f4"] }, diff --git a/tests/components/habitica/snapshots/test_binary_sensor.ambr b/tests/components/habitica/snapshots/test_binary_sensor.ambr index 247063f2ae8..64dbc160a1b 100644 --- a/tests/components/habitica/snapshots/test_binary_sensor.ambr +++ b/tests/components/habitica/snapshots/test_binary_sensor.ambr @@ -48,3 +48,52 @@ 'state': 'on', }) # --- +# name: test_binary_sensors[binary_sensor.test_user_s_party_quest_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_user_s_party_quest_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Quest status', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_quest_running', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_user_s_party_quest_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': "test-user's Party Quest status", + }), + 'context': , + 'entity_id': 'binary_sensor.test_user_s_party_quest_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index 30c0f9d66eb..89d6936f111 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -1064,6 +1064,360 @@ 'state': '2', }) # --- +# name: test_sensors[sensor.test_user_s_party_boss_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_user_s_party_boss_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': 'Boss health', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_boss_hp', + 'unit_of_measurement': 'HP', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_boss_health-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiNGNzRFNTIiIGQ9Ik0yIDQuNUw2LjE2NyAyIDEyIDUuMTY3IDE3LjgzMyAyIDIyIDQuNVYxMmwtNC4xNjcgNS44MzNMMTIgMjJsLTUuODMzLTQuMTY3TDIgMTJ6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGNjE2NSIgZD0iTTcuMzMzIDE2LjY2N0wzLjY2NyAxMS41VjUuNDE3bDIuNS0xLjVMMTIgNy4wODNsNS44MzMtMy4xNjYgMi41IDEuNVYxMS41bC0zLjY2NiA1LjE2N0wxMiAxOS45MTd6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M2w0LjY2NyAyLjU4NEwxMiAxOS45MTd6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNsLTQuNjY3IDIuNTg0TDEyIDE5LjkxN3oiIG9wYWNpdHk9Ii4zNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik03LjMzMyAxNi42NjdMMy42NjcgMTEuNSAxMiAxNC4wODN6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQjUyNDI4IiBkPSJNMTYuNjY3IDE2LjY2N2wzLjY2Ni01LjE2N0wxMiAxNC4wODN6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNsNS44MzMtMTAuMTY2IDIuNSAxLjVWMTEuNXoiIG9wYWNpdHk9Ii4zNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNMNi4xNjcgMy45MTdsLTIuNSAxLjVWMTEuNXoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M0w2LjE2NyAzLjkxNyAxMiA3LjA4M3oiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M2w1LjgzMy0xMC4xNjZMMTIgNy4wODN6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjRkZGIiBkPSJNOS4xNjcgMTQuODMzbC0zLTQuMTY2VjYuODMzaC4wODNMMTIgOS45MTdsNS43NS0zLjA4NGguMDgzdjMuODM0bC0zIDQuMTY2TDEyIDE2LjkxN3oiIG9wYWNpdHk9Ii41Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', + 'friendly_name': "test-user's Party Boss health", + 'unit_of_measurement': 'HP', + }), + 'context': , + 'entity_id': 'sensor.test_user_s_party_boss_health', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_boss_health_remaining-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_user_s_party_boss_health_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Boss health remaining', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_boss_hp_remaining', + 'unit_of_measurement': 'HP', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_boss_health_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiNGNzRFNTIiIGQ9Ik0yIDQuNUw2LjE2NyAyIDEyIDUuMTY3IDE3LjgzMyAyIDIyIDQuNVYxMmwtNC4xNjcgNS44MzNMMTIgMjJsLTUuODMzLTQuMTY3TDIgMTJ6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGNjE2NSIgZD0iTTcuMzMzIDE2LjY2N0wzLjY2NyAxMS41VjUuNDE3bDIuNS0xLjVMMTIgNy4wODNsNS44MzMtMy4xNjYgMi41IDEuNVYxMS41bC0zLjY2NiA1LjE2N0wxMiAxOS45MTd6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M2w0LjY2NyAyLjU4NEwxMiAxOS45MTd6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNsLTQuNjY3IDIuNTg0TDEyIDE5LjkxN3oiIG9wYWNpdHk9Ii4zNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik03LjMzMyAxNi42NjdMMy42NjcgMTEuNSAxMiAxNC4wODN6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQjUyNDI4IiBkPSJNMTYuNjY3IDE2LjY2N2wzLjY2Ni01LjE2N0wxMiAxNC4wODN6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNsNS44MzMtMTAuMTY2IDIuNSAxLjVWMTEuNXoiIG9wYWNpdHk9Ii4zNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNMNi4xNjcgMy45MTdsLTIuNSAxLjVWMTEuNXoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M0w2LjE2NyAzLjkxNyAxMiA3LjA4M3oiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M2w1LjgzMy0xMC4xNjZMMTIgNy4wODN6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjRkZGIiBkPSJNOS4xNjcgMTQuODMzbC0zLTQuMTY2VjYuODMzaC4wODNMMTIgOS45MTdsNS43NS0zLjA4NGguMDgzdjMuODM0bC0zIDQuMTY2TDEyIDE2LjkxN3oiIG9wYWNpdHk9Ii41Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', + 'friendly_name': "test-user's Party Boss health remaining", + 'unit_of_measurement': 'HP', + }), + 'context': , + 'entity_id': 'sensor.test_user_s_party_boss_health_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_collected_quest_items-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_user_s_party_collected_quest_items', + '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': 'Collected quest items', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_collected_items', + 'unit_of_measurement': 'items', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_collected_quest_items-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Seifenstücke': '10 / 20', + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/quest_atom1_soapBars.png', + 'friendly_name': "test-user's Party Collected quest items", + 'unit_of_measurement': 'items', + }), + 'context': , + 'entity_id': 'sensor.test_user_s_party_collected_quest_items', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_group_leader-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_user_s_party_group_leader', + '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': 'Group leader', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_group_leader', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_user_s_party_group_leader-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': "test-user's Party Group leader", + }), + 'context': , + 'entity_id': 'sensor.test_user_s_party_group_leader', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'test-user', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_member_count-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_user_s_party_member_count', + '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': 'Member count', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_member_count', + 'unit_of_measurement': 'members', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_member_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii02IC02IDQ2IDUwIj48dGl0bGU+QnJvbnplX1NtYWxsPC90aXRsZT48cGF0aCBkPSJNMjAsMzYuMjhDNy4xOCwzMC42MSw1LjYsMjQuNjksNS41LDEwLjQyTDIwLDMuNzVsMTQuNSw2LjY3QzM0LjQsMjQuNjksMzIuODIsMzAuNjEsMjAsMzYuMjhaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMyAtMSkiIGZpbGw9IiNlYThjMzEiPjwvcGF0aD48cGF0aCBkPSJNMjAsNi41TDMyLDEyYy0wLjE1LDExLjU2LTEuNTEsMTYuNjItMTIsMjEuNTFDOS41MywyOC42NCw4LjE3LDIzLjU4LDgsMTJMMjAsNi41TTIwLDFMMyw4LjgyQzMsMjQuNDcsNC4xMywzMi4yOSwyMCwzOSwzNS44NywzMi4yOSwzNywyNC40NywzNyw4LjgyTDIwLDFoMFoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0zIC0xKSIgZmlsbD0iI2IzNjIxMyI+PC9wYXRoPjxwYXRoIGQ9Ik0yMCw0LjNsMTQsNi40NGMtMC4xMiwxMy43Mi0xLjcyLDE5LjUxLTE0LDI1QzcuNzMsMzAuMjUsNi4xMiwyNC40Niw2LDEwLjc0TDIwLDQuM00yMCwxTDMsOC44MkMzLDI0LjQ3LDQuMTMsMzIuMjksMjAsMzksMzUuODcsMzIuMjksMzcsMjQuNDcsMzcsOC44MkwyMCwxaDBaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMyAtMSkiIGZpbGw9IiNkNzdhMjAiPjwvcGF0aD48L3N2Zz4=', + 'friendly_name': "test-user's Party Member count", + 'unit_of_measurement': 'members', + }), + 'context': , + 'entity_id': 'sensor.test_user_s_party_member_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_quest-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_user_s_party_quest', + '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': 'Quest', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_quest', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_user_s_party_quest-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/inventory_quest_scroll_atom1.png', + 'friendly_name': "test-user's Party Quest", + 'quest_details': 'Du erreichst die Ufer des Waschbeckensees für eine wohlverdiente Auszeit ... Aber der See ist verschmutzt mit nicht abgespültem Geschirr! Wie ist das passiert? Wie auch immer, Du kannst den See jedenfalls nicht in diesem Zustand lassen. Es gibt nur eine Sache die Du tun kannst: Abspülen und den Ferienort retten! Dazu musst Du aber Seife für den Abwasch finden. Viel Seife ...', + 'quest_participants': '1 / 2', + }), + 'context': , + 'entity_id': 'sensor.test_user_s_party_quest', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Angriff des Banalen, Teil 1: Abwasch-Katastrophe!', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_quest_boss-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_user_s_party_quest_boss', + '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': 'Quest boss', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_boss', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_user_s_party_quest_boss-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': "test-user's Party Quest boss", + }), + 'context': , + 'entity_id': 'sensor.test_user_s_party_quest_boss', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensors[sensor.test_user_saddles-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/habitica/test_init.py b/tests/components/habitica/test_init.py index e904ccc890d..92be6cbe881 100644 --- a/tests/components/habitica/test_init.py +++ b/tests/components/habitica/test_init.py @@ -6,11 +6,13 @@ from unittest.mock import AsyncMock from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory +from habiticalib import HabiticaUserResponse import pytest from homeassistant.components.habitica.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from .conftest import ( ERROR_BAD_REQUEST, @@ -19,7 +21,7 @@ from .conftest import ( ERROR_TOO_MANY_REQUESTS, ) -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, async_load_fixture @pytest.mark.usefixtures("habitica") @@ -128,3 +130,41 @@ async def test_coordinator_rate_limited( await hass.async_block_till_done() assert "Rate limit exceeded, will try again later" in caplog.text + + +async def test_remove_party_and_reload( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + freezer: FrozenDateTimeFactory, + device_registry: dr.DeviceRegistry, +) -> None: + """Test we leave the party and device is removed.""" + group_id = "1e87097c-4c03-4f8c-a475-67cc7da7f409" + 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 ConfigEntryState.LOADED + + assert ( + device_registry.async_get_device( + {(DOMAIN, f"{config_entry.unique_id}_{group_id}")} + ) + is not None + ) + + habitica.get_user.return_value = HabiticaUserResponse.from_json( + await async_load_fixture(hass, "user_no_party.json", DOMAIN) + ) + + freezer.tick(datetime.timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + device_registry.async_get_device( + {(DOMAIN, f"{config_entry.unique_id}_{group_id}")} + ) + is None + )