452 lines
16 KiB
Python
452 lines
16 KiB
Python
"""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
|