mirror of
https://github.com/home-assistant/core.git
synced 2025-08-31 18:31:35 +02:00
Add party to Habitica (#149608)
This commit is contained in:
@@ -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)
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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": {
|
||||
|
@@ -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 {}
|
||||
)
|
||||
|
@@ -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
|
||||
|
||||
|
||||
|
75
tests/components/habitica/fixtures/party.json
Normal file
75
tests/components/habitica/fixtures/party.json
Normal 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"
|
||||
}
|
@@ -76,7 +76,7 @@
|
||||
"RSVPNeeded": true,
|
||||
"key": "dustbunnies"
|
||||
},
|
||||
"_id": "94cd398c-2240-4320-956e-6d345cf2c0de"
|
||||
"_id": "1e87097c-4c03-4f8c-a475-67cc7da7f409"
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
|
@@ -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"]
|
||||
},
|
||||
|
@@ -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',
|
||||
})
|
||||
# ---
|
||||
|
@@ -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({
|
||||
|
@@ -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
|
||||
)
|
||||
|
Reference in New Issue
Block a user