Home Assistant Unofficial Reference 2024.12.1
services.py
Go to the documentation of this file.
1 """Actions for the Habitica integration."""
2 
3 from __future__ import annotations
4 
5 from http import HTTPStatus
6 import logging
7 from typing import Any
8 
9 from aiohttp import ClientResponseError
10 import voluptuous as vol
11 
12 from homeassistant.config_entries import ConfigEntryState
13 from homeassistant.const import ATTR_NAME, CONF_NAME
14 from homeassistant.core import (
15  HomeAssistant,
16  ServiceCall,
17  ServiceResponse,
18  SupportsResponse,
19 )
20 from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
21 from homeassistant.helpers import config_validation as cv
22 from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
23 from homeassistant.helpers.selector import ConfigEntrySelector
24 
25 from .const import (
26  ATTR_ARGS,
27  ATTR_CONFIG_ENTRY,
28  ATTR_DATA,
29  ATTR_DIRECTION,
30  ATTR_ITEM,
31  ATTR_PATH,
32  ATTR_SKILL,
33  ATTR_TARGET,
34  ATTR_TASK,
35  DOMAIN,
36  EVENT_API_CALL_SUCCESS,
37  SERVICE_ABORT_QUEST,
38  SERVICE_ACCEPT_QUEST,
39  SERVICE_API_CALL,
40  SERVICE_CANCEL_QUEST,
41  SERVICE_CAST_SKILL,
42  SERVICE_LEAVE_QUEST,
43  SERVICE_REJECT_QUEST,
44  SERVICE_SCORE_HABIT,
45  SERVICE_SCORE_REWARD,
46  SERVICE_START_QUEST,
47  SERVICE_TRANSFORMATION,
48 )
49 from .types import HabiticaConfigEntry
50 
51 _LOGGER = logging.getLogger(__name__)
52 
53 
54 SERVICE_API_CALL_SCHEMA = vol.Schema(
55  {
56  vol.Required(ATTR_NAME): str,
57  vol.Required(ATTR_PATH): vol.All(cv.ensure_list, [str]),
58  vol.Optional(ATTR_ARGS): dict,
59  }
60 )
61 
62 SERVICE_CAST_SKILL_SCHEMA = vol.Schema(
63  {
64  vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
65  vol.Required(ATTR_SKILL): cv.string,
66  vol.Optional(ATTR_TASK): cv.string,
67  }
68 )
69 
70 SERVICE_MANAGE_QUEST_SCHEMA = vol.Schema(
71  {
72  vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
73  }
74 )
75 SERVICE_SCORE_TASK_SCHEMA = vol.Schema(
76  {
77  vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
78  vol.Required(ATTR_TASK): cv.string,
79  vol.Optional(ATTR_DIRECTION): cv.string,
80  }
81 )
82 
83 SERVICE_TRANSFORMATION_SCHEMA = vol.Schema(
84  {
85  vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
86  vol.Required(ATTR_ITEM): cv.string,
87  vol.Required(ATTR_TARGET): cv.string,
88  }
89 )
90 
91 
92 def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry:
93  """Return config entry or raise if not found or not loaded."""
94  if not (entry := hass.config_entries.async_get_entry(entry_id)):
96  translation_domain=DOMAIN,
97  translation_key="entry_not_found",
98  )
99  if entry.state is not ConfigEntryState.LOADED:
101  translation_domain=DOMAIN,
102  translation_key="entry_not_loaded",
103  )
104  return entry
105 
106 
107 def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
108  """Set up services for Habitica integration."""
109 
110  async def handle_api_call(call: ServiceCall) -> None:
112  hass,
113  DOMAIN,
114  "deprecated_api_call",
115  breaks_in_ha_version="2025.6.0",
116  is_fixable=False,
117  severity=IssueSeverity.WARNING,
118  translation_key="deprecated_api_call",
119  )
120  _LOGGER.warning(
121  "Deprecated action called: 'habitica.api_call' is deprecated and will be removed in Home Assistant version 2025.6.0"
122  )
123 
124  name = call.data[ATTR_NAME]
125  path = call.data[ATTR_PATH]
126  entries = hass.config_entries.async_entries(DOMAIN)
127 
128  api = None
129  for entry in entries:
130  if entry.data[CONF_NAME] == name:
131  api = entry.runtime_data.api
132  break
133  if api is None:
134  _LOGGER.error("API_CALL: User '%s' not configured", name)
135  return
136  try:
137  for element in path:
138  api = api[element]
139  except KeyError:
140  _LOGGER.error(
141  "API_CALL: Path %s is invalid for API on '{%s}' element", path, element
142  )
143  return
144  kwargs = call.data.get(ATTR_ARGS, {})
145  data = await api(**kwargs)
146  hass.bus.async_fire(
147  EVENT_API_CALL_SUCCESS, {ATTR_NAME: name, ATTR_PATH: path, ATTR_DATA: data}
148  )
149 
150  async def cast_skill(call: ServiceCall) -> ServiceResponse:
151  """Skill action."""
152  entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
153  coordinator = entry.runtime_data
154  skill = {
155  "pickpocket": {"spellId": "pickPocket", "cost": "10 MP"},
156  "backstab": {"spellId": "backStab", "cost": "15 MP"},
157  "smash": {"spellId": "smash", "cost": "10 MP"},
158  "fireball": {"spellId": "fireball", "cost": "10 MP"},
159  }
160  try:
161  task_id = next(
162  task["id"]
163  for task in coordinator.data.tasks
164  if call.data[ATTR_TASK] in (task["id"], task.get("alias"))
165  or call.data[ATTR_TASK] == task["text"]
166  )
167  except StopIteration as e:
169  translation_domain=DOMAIN,
170  translation_key="task_not_found",
171  translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"},
172  ) from e
173 
174  try:
175  response: dict[str, Any] = await coordinator.api.user.class_.cast[
176  skill[call.data[ATTR_SKILL]]["spellId"]
177  ].post(targetId=task_id)
178  except ClientResponseError as e:
179  if e.status == HTTPStatus.TOO_MANY_REQUESTS:
181  translation_domain=DOMAIN,
182  translation_key="setup_rate_limit_exception",
183  ) from e
184  if e.status == HTTPStatus.UNAUTHORIZED:
186  translation_domain=DOMAIN,
187  translation_key="not_enough_mana",
188  translation_placeholders={
189  "cost": skill[call.data[ATTR_SKILL]]["cost"],
190  "mana": f"{int(coordinator.data.user.get("stats", {}).get("mp", 0))} MP",
191  },
192  ) from e
193  if e.status == HTTPStatus.NOT_FOUND:
194  # could also be task not found, but the task is looked up
195  # before the request, so most likely wrong skill selected
196  # or the skill hasn't been unlocked yet.
198  translation_domain=DOMAIN,
199  translation_key="skill_not_found",
200  translation_placeholders={"skill": call.data[ATTR_SKILL]},
201  ) from e
202  raise HomeAssistantError(
203  translation_domain=DOMAIN,
204  translation_key="service_call_exception",
205  ) from e
206  else:
207  await coordinator.async_request_refresh()
208  return response
209 
210  async def manage_quests(call: ServiceCall) -> ServiceResponse:
211  """Accept, reject, start, leave or cancel quests."""
212  entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
213  coordinator = entry.runtime_data
214 
215  COMMAND_MAP = {
216  SERVICE_ABORT_QUEST: "abort",
217  SERVICE_ACCEPT_QUEST: "accept",
218  SERVICE_CANCEL_QUEST: "cancel",
219  SERVICE_LEAVE_QUEST: "leave",
220  SERVICE_REJECT_QUEST: "reject",
221  SERVICE_START_QUEST: "force-start",
222  }
223  try:
224  return await coordinator.api.groups.party.quests[
225  COMMAND_MAP[call.service]
226  ].post()
227  except ClientResponseError as e:
228  if e.status == HTTPStatus.TOO_MANY_REQUESTS:
230  translation_domain=DOMAIN,
231  translation_key="setup_rate_limit_exception",
232  ) from e
233  if e.status == HTTPStatus.UNAUTHORIZED:
235  translation_domain=DOMAIN, translation_key="quest_action_unallowed"
236  ) from e
237  if e.status == HTTPStatus.NOT_FOUND:
239  translation_domain=DOMAIN, translation_key="quest_not_found"
240  ) from e
241  raise HomeAssistantError(
242  translation_domain=DOMAIN, translation_key="service_call_exception"
243  ) from e
244 
245  for service in (
246  SERVICE_ABORT_QUEST,
247  SERVICE_ACCEPT_QUEST,
248  SERVICE_CANCEL_QUEST,
249  SERVICE_LEAVE_QUEST,
250  SERVICE_REJECT_QUEST,
251  SERVICE_START_QUEST,
252  ):
253  hass.services.async_register(
254  DOMAIN,
255  service,
256  manage_quests,
257  schema=SERVICE_MANAGE_QUEST_SCHEMA,
258  supports_response=SupportsResponse.ONLY,
259  )
260 
261  async def score_task(call: ServiceCall) -> ServiceResponse:
262  """Score a task action."""
263  entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
264  coordinator = entry.runtime_data
265  try:
266  task_id, task_value = next(
267  (task["id"], task.get("value"))
268  for task in coordinator.data.tasks
269  if call.data[ATTR_TASK] in (task["id"], task.get("alias"))
270  or call.data[ATTR_TASK] == task["text"]
271  )
272  except StopIteration as e:
274  translation_domain=DOMAIN,
275  translation_key="task_not_found",
276  translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"},
277  ) from e
278 
279  try:
280  response: dict[str, Any] = (
281  await coordinator.api.tasks[task_id]
282  .score[call.data.get(ATTR_DIRECTION, "up")]
283  .post()
284  )
285  except ClientResponseError as e:
286  if e.status == HTTPStatus.TOO_MANY_REQUESTS:
288  translation_domain=DOMAIN,
289  translation_key="setup_rate_limit_exception",
290  ) from e
291  if e.status == HTTPStatus.UNAUTHORIZED and task_value is not None:
293  translation_domain=DOMAIN,
294  translation_key="not_enough_gold",
295  translation_placeholders={
296  "gold": f"{coordinator.data.user["stats"]["gp"]:.2f} GP",
297  "cost": f"{task_value} GP",
298  },
299  ) from e
300  raise HomeAssistantError(
301  translation_domain=DOMAIN,
302  translation_key="service_call_exception",
303  ) from e
304  else:
305  await coordinator.async_request_refresh()
306  return response
307 
308  async def transformation(call: ServiceCall) -> ServiceResponse:
309  """User a transformation item on a player character."""
310 
311  entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
312  coordinator = entry.runtime_data
313  ITEMID_MAP = {
314  "snowball": {"itemId": "snowball"},
315  "spooky_sparkles": {"itemId": "spookySparkles"},
316  "seafoam": {"itemId": "seafoam"},
317  "shiny_seed": {"itemId": "shinySeed"},
318  }
319  # check if target is self
320  if call.data[ATTR_TARGET] in (
321  coordinator.data.user["id"],
322  coordinator.data.user["profile"]["name"],
323  coordinator.data.user["auth"]["local"]["username"],
324  ):
325  target_id = coordinator.data.user["id"]
326  else:
327  # check if target is a party member
328  try:
329  party = await coordinator.api.groups.party.members.get()
330  except ClientResponseError as e:
331  if e.status == HTTPStatus.TOO_MANY_REQUESTS:
333  translation_domain=DOMAIN,
334  translation_key="setup_rate_limit_exception",
335  ) from e
336  if e.status == HTTPStatus.NOT_FOUND:
338  translation_domain=DOMAIN,
339  translation_key="party_not_found",
340  ) from e
341  raise HomeAssistantError(
342  translation_domain=DOMAIN,
343  translation_key="service_call_exception",
344  ) from e
345  try:
346  target_id = next(
347  member["id"]
348  for member in party
349  if call.data[ATTR_TARGET].lower()
350  in (
351  member["id"],
352  member["auth"]["local"]["username"].lower(),
353  member["profile"]["name"].lower(),
354  )
355  )
356  except StopIteration as e:
358  translation_domain=DOMAIN,
359  translation_key="target_not_found",
360  translation_placeholders={"target": f"'{call.data[ATTR_TARGET]}'"},
361  ) from e
362  try:
363  response: dict[str, Any] = await coordinator.api.user.class_.cast[
364  ITEMID_MAP[call.data[ATTR_ITEM]]["itemId"]
365  ].post(targetId=target_id)
366  except ClientResponseError as e:
367  if e.status == HTTPStatus.TOO_MANY_REQUESTS:
369  translation_domain=DOMAIN,
370  translation_key="setup_rate_limit_exception",
371  ) from e
372  if e.status == HTTPStatus.UNAUTHORIZED:
374  translation_domain=DOMAIN,
375  translation_key="item_not_found",
376  translation_placeholders={"item": call.data[ATTR_ITEM]},
377  ) from e
378  raise HomeAssistantError(
379  translation_domain=DOMAIN,
380  translation_key="service_call_exception",
381  ) from e
382  else:
383  return response
384 
385  hass.services.async_register(
386  DOMAIN,
387  SERVICE_API_CALL,
388  handle_api_call,
389  schema=SERVICE_API_CALL_SCHEMA,
390  )
391 
392  hass.services.async_register(
393  DOMAIN,
394  SERVICE_CAST_SKILL,
395  cast_skill,
396  schema=SERVICE_CAST_SKILL_SCHEMA,
397  supports_response=SupportsResponse.ONLY,
398  )
399 
400  hass.services.async_register(
401  DOMAIN,
402  SERVICE_SCORE_HABIT,
403  score_task,
404  schema=SERVICE_SCORE_TASK_SCHEMA,
405  supports_response=SupportsResponse.ONLY,
406  )
407  hass.services.async_register(
408  DOMAIN,
409  SERVICE_SCORE_REWARD,
410  score_task,
411  schema=SERVICE_SCORE_TASK_SCHEMA,
412  supports_response=SupportsResponse.ONLY,
413  )
414 
415  hass.services.async_register(
416  DOMAIN,
417  SERVICE_TRANSFORMATION,
418  transformation,
419  schema=SERVICE_TRANSFORMATION_SCHEMA,
420  supports_response=SupportsResponse.ONLY,
421  )
web.Response post(self, web.Request request, str config_key)
Definition: view.py:101
HabiticaConfigEntry get_config_entry(HomeAssistant hass, str entry_id)
Definition: services.py:92
None async_setup_services(HomeAssistant hass)
Definition: services.py:107
None async_create_issue(HomeAssistant hass, str entry_id)
Definition: repairs.py:69