Add party to Habitica (#149608)

This commit is contained in:
Manu
2025-08-12 20:51:12 +02:00
committed by GitHub
parent ed6072d46b
commit 6fa7c6cb81
17 changed files with 1023 additions and 54 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
}
}
},

View File

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

View File

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

View File

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

View File

@@ -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": {

View File

@@ -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 {}
)

View File

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

View File

@@ -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"
}

View File

@@ -76,7 +76,7 @@
"RSVPNeeded": true,
"key": "dustbunnies"
},
"_id": "94cd398c-2240-4320-956e-6d345cf2c0de"
"_id": "1e87097c-4c03-4f8c-a475-67cc7da7f409"
},
"tags": [
{

View File

@@ -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"]
},

View File

@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
'original_icon': None,
'original_name': 'Quest status',
'platform': 'habitica',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <HabiticaBinarySensor.QUEST_RUNNING: 'quest_running'>,
'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': <ANY>,
'entity_id': 'binary_sensor.test_user_s_party_quest_status',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---

View File

@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <HabiticaSensorEntity.BOSS_HP: 'boss_hp'>,
'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': <ANY>,
'entity_id': 'sensor.test_user_s_party_boss_health',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <HabiticaSensorEntity.BOSS_HP_REMAINING: 'boss_hp_remaining'>,
'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': <ANY>,
'entity_id': 'sensor.test_user_s_party_boss_health_remaining',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <HabiticaSensorEntity.COLLECTED_ITEMS: 'collected_items'>,
'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': <ANY>,
'entity_id': 'sensor.test_user_s_party_collected_quest_items',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '10',
})
# ---
# name: test_sensors[sensor.test_user_s_party_group_leader-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <HabiticaSensorEntity.GROUP_LEADER: 'group_leader'>,
'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': <ANY>,
'entity_id': 'sensor.test_user_s_party_group_leader',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <HabiticaSensorEntity.MEMBER_COUNT: 'member_count'>,
'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': <ANY>,
'entity_id': 'sensor.test_user_s_party_member_count',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2',
})
# ---
# name: test_sensors[sensor.test_user_s_party_quest-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <HabiticaSensorEntity.QUEST: 'quest'>,
'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': <ANY>,
'entity_id': 'sensor.test_user_s_party_quest',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <HabiticaSensorEntity.BOSS: 'boss'>,
'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': <ANY>,
'entity_id': 'sensor.test_user_s_party_quest_boss',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensors[sensor.test_user_saddles-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

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