"""Support for Habitica sensors.""" from __future__ import annotations from collections.abc import Callable, Mapping from dataclasses import asdict, dataclass from enum import StrEnum import logging from typing import Any from habiticalib import ( ContentData, HabiticaClass, TaskData, TaskType, UserData, deserialize_task, ha, ) from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, async_delete_issue, ) from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util from .const import ASSETS_URL, DOMAIN from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator from .entity import HabiticaBase from .util import get_attribute_points, get_attributes_total, inventory_list _LOGGER = logging.getLogger(__name__) SVG_CLASS = { HabiticaClass.WARRIOR: ha.WARRIOR, HabiticaClass.ROGUE: ha.ROGUE, HabiticaClass.MAGE: ha.WIZARD, HabiticaClass.HEALER: ha.HEALER, } PARALLEL_UPDATES = 1 @dataclass(kw_only=True, frozen=True) class HabiticaSensorEntityDescription(SensorEntityDescription): """Habitica Sensor Description.""" value_fn: Callable[[UserData, ContentData], StateType] attributes_fn: Callable[[UserData, ContentData], dict[str, Any] | None] | None = ( None ) entity_picture: str | None = None @dataclass(kw_only=True, frozen=True) class HabiticaTaskSensorEntityDescription(SensorEntityDescription): """Habitica Task Sensor Description.""" value_fn: Callable[[list[TaskData]], list[TaskData]] class HabiticaSensorEntity(StrEnum): """Habitica Entities.""" DISPLAY_NAME = "display_name" HEALTH = "health" HEALTH_MAX = "health_max" MANA = "mana" MANA_MAX = "mana_max" EXPERIENCE = "experience" EXPERIENCE_MAX = "experience_max" LEVEL = "level" GOLD = "gold" CLASS = "class" HABITS = "habits" REWARDS = "rewards" GEMS = "gems" TRINKETS = "trinkets" STRENGTH = "strength" INTELLIGENCE = "intelligence" CONSTITUTION = "constitution" PERCEPTION = "perception" EGGS_TOTAL = "eggs_total" HATCHING_POTIONS_TOTAL = "hatching_potions_total" FOOD_TOTAL = "food_total" SADDLE = "saddle" QUEST_SCROLLS = "quest_scrolls" SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( HabiticaSensorEntityDescription( key=HabiticaSensorEntity.DISPLAY_NAME, translation_key=HabiticaSensorEntity.DISPLAY_NAME, value_fn=lambda user, _: user.profile.name, attributes_fn=lambda user, _: { "blurb": user.profile.blurb, "joined": ( dt_util.as_local(joined).date() if (joined := user.auth.timestamps.created) else None ), "last_login": ( dt_util.as_local(last).date() if (last := user.auth.timestamps.loggedin) else None ), "total_logins": user.loginIncentives, }, ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.HEALTH, translation_key=HabiticaSensorEntity.HEALTH, suggested_display_precision=0, value_fn=lambda user, _: user.stats.hp, entity_picture=ha.HP, ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.HEALTH_MAX, translation_key=HabiticaSensorEntity.HEALTH_MAX, entity_registry_enabled_default=False, value_fn=lambda user, _: 50, ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.MANA, translation_key=HabiticaSensorEntity.MANA, suggested_display_precision=0, value_fn=lambda user, _: user.stats.mp, entity_picture=ha.MP, ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.MANA_MAX, translation_key=HabiticaSensorEntity.MANA_MAX, value_fn=lambda user, _: user.stats.maxMP, entity_picture=ha.MP, ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.EXPERIENCE, translation_key=HabiticaSensorEntity.EXPERIENCE, value_fn=lambda user, _: user.stats.exp, entity_picture=ha.XP, ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.EXPERIENCE_MAX, translation_key=HabiticaSensorEntity.EXPERIENCE_MAX, value_fn=lambda user, _: user.stats.toNextLevel, entity_picture=ha.XP, ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.LEVEL, translation_key=HabiticaSensorEntity.LEVEL, value_fn=lambda user, _: user.stats.lvl, ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.GOLD, translation_key=HabiticaSensorEntity.GOLD, suggested_display_precision=2, value_fn=lambda user, _: user.stats.gp, entity_picture=ha.GP, ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.CLASS, translation_key=HabiticaSensorEntity.CLASS, value_fn=lambda user, _: user.stats.Class.value if user.stats.Class else None, device_class=SensorDeviceClass.ENUM, options=[item.value for item in HabiticaClass], ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.GEMS, translation_key=HabiticaSensorEntity.GEMS, value_fn=lambda user, _: None if (b := user.balance) is None else round(b * 4), suggested_display_precision=0, entity_picture="shop_gem.png", ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.TRINKETS, translation_key=HabiticaSensorEntity.TRINKETS, value_fn=lambda user, _: user.purchased.plan.consecutive.trinkets, suggested_display_precision=0, native_unit_of_measurement="⧖", entity_picture="notif_subscriber_reward.png", ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.STRENGTH, translation_key=HabiticaSensorEntity.STRENGTH, value_fn=lambda user, content: get_attributes_total(user, content, "Str"), attributes_fn=lambda user, content: get_attribute_points(user, content, "Str"), suggested_display_precision=0, native_unit_of_measurement="STR", ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.INTELLIGENCE, translation_key=HabiticaSensorEntity.INTELLIGENCE, value_fn=lambda user, content: get_attributes_total(user, content, "Int"), attributes_fn=lambda user, content: get_attribute_points(user, content, "Int"), suggested_display_precision=0, native_unit_of_measurement="INT", ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.PERCEPTION, translation_key=HabiticaSensorEntity.PERCEPTION, value_fn=lambda user, content: get_attributes_total(user, content, "per"), attributes_fn=lambda user, content: get_attribute_points(user, content, "per"), suggested_display_precision=0, native_unit_of_measurement="PER", ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.CONSTITUTION, translation_key=HabiticaSensorEntity.CONSTITUTION, value_fn=lambda user, content: get_attributes_total(user, content, "con"), attributes_fn=lambda user, content: get_attribute_points(user, content, "con"), suggested_display_precision=0, native_unit_of_measurement="CON", ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.EGGS_TOTAL, translation_key=HabiticaSensorEntity.EGGS_TOTAL, value_fn=lambda user, _: sum(n for n in user.items.eggs.values()), entity_picture="Pet_Egg_Egg.png", attributes_fn=lambda user, content: inventory_list(user, content, "eggs"), ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.HATCHING_POTIONS_TOTAL, translation_key=HabiticaSensorEntity.HATCHING_POTIONS_TOTAL, value_fn=lambda user, _: sum(n for n in user.items.hatchingPotions.values()), entity_picture="Pet_HatchingPotion_RoyalPurple.png", attributes_fn=( lambda user, content: inventory_list(user, content, "hatchingPotions") ), ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.FOOD_TOTAL, translation_key=HabiticaSensorEntity.FOOD_TOTAL, value_fn=( lambda user, _: sum(n for k, n in user.items.food.items() if k != "Saddle") ), entity_picture=ha.FOOD, attributes_fn=lambda user, content: inventory_list(user, content, "food"), ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.SADDLE, translation_key=HabiticaSensorEntity.SADDLE, value_fn=lambda user, _: user.items.food.get("Saddle", 0), entity_picture="Pet_Food_Saddle.png", ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.QUEST_SCROLLS, translation_key=HabiticaSensorEntity.QUEST_SCROLLS, value_fn=(lambda user, _: sum(n for n in user.items.quests.values())), entity_picture="inventory_quest_scroll_dustbunnies.png", attributes_fn=lambda user, content: inventory_list(user, content, "quests"), ), ) TASKS_MAP_ID = "id" TASKS_MAP = { "repeat": "repeat", "challenge": "challenge", "group": "group", "frequency": "frequency", "every_x": "everyX", "streak": "streak", "up": "up", "down": "down", "counter_up": "counterUp", "counter_down": "counterDown", "next_due": "nextDue", "yester_daily": "yesterDaily", "completed": "completed", "collapse_checklist": "collapseChecklist", "type": "Type", "notes": "notes", "tags": "tags", "value": "value", "priority": "priority", "start_date": "startDate", "days_of_month": "daysOfMonth", "weeks_of_month": "weeksOfMonth", "created_at": "createdAt", "text": "text", "is_due": "isDue", } TASK_SENSOR_DESCRIPTION: tuple[HabiticaTaskSensorEntityDescription, ...] = ( HabiticaTaskSensorEntityDescription( key=HabiticaSensorEntity.HABITS, translation_key=HabiticaSensorEntity.HABITS, value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.HABIT], ), HabiticaTaskSensorEntityDescription( key=HabiticaSensorEntity.REWARDS, translation_key=HabiticaSensorEntity.REWARDS, value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.REWARD], ), ) def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]: """Get list of related automations and scripts.""" used_in = automations_with_entity(hass, entity_id) used_in += scripts_with_entity(hass, entity_id) return used_in async def async_setup_entry( hass: HomeAssistant, config_entry: HabiticaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the habitica sensors.""" coordinator = config_entry.runtime_data ent_reg = er.async_get(hass) entities: list[SensorEntity] = [] description: SensorEntityDescription def add_deprecated_entity( description: SensorEntityDescription, entity_cls: Callable[ [HabiticaDataUpdateCoordinator, SensorEntityDescription], SensorEntity ], ) -> None: """Add deprecated entities.""" if entity_id := ent_reg.async_get_entity_id( SENSOR_DOMAIN, DOMAIN, f"{config_entry.unique_id}_{description.key}", ): entity_entry = ent_reg.async_get(entity_id) if entity_entry and entity_entry.disabled: ent_reg.async_remove(entity_id) async_delete_issue( hass, DOMAIN, f"deprecated_entity_{description.key}", ) elif entity_entry: entities.append(entity_cls(coordinator, description)) if entity_used_in(hass, entity_id): async_create_issue( hass, DOMAIN, f"deprecated_entity_{description.key}", breaks_in_ha_version="2025.8.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_entity", translation_placeholders={ "name": str( entity_entry.name or entity_entry.original_name ), "entity": entity_id, }, ) for description in SENSOR_DESCRIPTIONS: if description.key is HabiticaSensorEntity.HEALTH_MAX: add_deprecated_entity(description, HabiticaSensor) else: entities.append(HabiticaSensor(coordinator, description)) for description in TASK_SENSOR_DESCRIPTION: add_deprecated_entity(description, HabiticaTaskSensor) async_add_entities(entities, True) class HabiticaSensor(HabiticaBase, SensorEntity): """A generic Habitica sensor.""" entity_description: HabiticaSensorEntityDescription @property def native_value(self) -> StateType: """Return the state of the device.""" return self.entity_description.value_fn( self.coordinator.data.user, self.coordinator.content ) @property def extra_state_attributes(self) -> dict[str, float | None] | None: """Return entity specific state attributes.""" if func := self.entity_description.attributes_fn: return func(self.coordinator.data.user, self.coordinator.content) return None @property def entity_picture(self) -> str | None: """Return the entity picture to use in the frontend, if any.""" if self.entity_description.key is HabiticaSensorEntity.CLASS and ( _class := self.coordinator.data.user.stats.Class ): return SVG_CLASS[_class] if self.entity_description.key is HabiticaSensorEntity.DISPLAY_NAME and ( img_url := self.coordinator.data.user.profile.imageUrl ): return img_url if entity_picture := self.entity_description.entity_picture: return ( entity_picture if entity_picture.startswith("data:image") else f"{ASSETS_URL}{entity_picture}" ) return None class HabiticaTaskSensor(HabiticaBase, SensorEntity): """A Habitica task sensor.""" entity_description: HabiticaTaskSensorEntityDescription @property def native_value(self) -> StateType: """Return the state of the device.""" return len(self.entity_description.value_fn(self.coordinator.data.tasks)) @property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes of all user tasks.""" attrs = {} # Map tasks to TASKS_MAP for task_data in self.entity_description.value_fn(self.coordinator.data.tasks): received_task = deserialize_task(asdict(task_data)) task_id = received_task[TASKS_MAP_ID] task = {} for map_key, map_value in TASKS_MAP.items(): if value := received_task.get(map_value): task[map_key] = value attrs[str(task_id)] = task return attrs