diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index bd1363ca979..ecaa66378f0 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -40,6 +40,10 @@ ATTR_ALIAS = "alias" ATTR_PRIORITY = "priority" ATTR_COST = "cost" ATTR_NOTES = "notes" +ATTR_UP_DOWN = "up_down" +ATTR_FREQUENCY = "frequency" +ATTR_COUNTER_UP = "counter_up" +ATTR_COUNTER_DOWN = "counter_down" SERVICE_CAST_SKILL = "cast_skill" SERVICE_START_QUEST = "start_quest" @@ -57,6 +61,7 @@ SERVICE_TRANSFORMATION = "transformation" SERVICE_UPDATE_REWARD = "update_reward" SERVICE_CREATE_REWARD = "create_reward" +SERVICE_UPDATE_HABIT = "update_habit" DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf" X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}" diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index 83df86f3945..ca4795dd514 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -230,6 +230,13 @@ "sections": { "developer_options": "mdi:test-tube" } + }, + "update_habit": { + "service": "mdi:contrast-box", + "sections": { + "tag_options": "mdi:tag", + "developer_options": "mdi:test-tube" + } } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 1abe977681f..3c4a59990a3 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -10,6 +10,7 @@ from uuid import UUID from aiohttp import ClientError from habiticalib import ( Direction, + Frequency, HabiticaException, NotAuthorizedError, NotFoundError, @@ -41,8 +42,11 @@ from .const import ( ATTR_ARGS, ATTR_CONFIG_ENTRY, ATTR_COST, + ATTR_COUNTER_DOWN, + ATTR_COUNTER_UP, ATTR_DATA, ATTR_DIRECTION, + ATTR_FREQUENCY, ATTR_ITEM, ATTR_KEYWORD, ATTR_NOTES, @@ -54,6 +58,7 @@ from .const import ( ATTR_TARGET, ATTR_TASK, ATTR_TYPE, + ATTR_UP_DOWN, DOMAIN, EVENT_API_CALL_SUCCESS, SERVICE_ABORT_QUEST, @@ -69,6 +74,7 @@ from .const import ( SERVICE_SCORE_REWARD, SERVICE_START_QUEST, SERVICE_TRANSFORMATION, + SERVICE_UPDATE_HABIT, SERVICE_UPDATE_REWARD, ) from .coordinator import HabiticaConfigEntry @@ -123,6 +129,13 @@ BASE_TASK_SCHEMA = vol.Schema( cv.string, cv.matches_regex("^[a-zA-Z0-9-_]*$") ), vol.Optional(ATTR_COST): vol.All(vol.Coerce(float), vol.Range(0)), + vol.Optional(ATTR_PRIORITY): vol.All( + vol.Upper, vol.In(TaskPriority._member_names_) + ), + vol.Optional(ATTR_UP_DOWN): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_COUNTER_UP): vol.All(int, vol.Range(0)), + vol.Optional(ATTR_COUNTER_DOWN): vol.All(int, vol.Range(0)), + vol.Optional(ATTR_FREQUENCY): vol.Coerce(Frequency), } ) @@ -173,6 +186,12 @@ ITEMID_MAP = { "shiny_seed": Skill.SHINY_SEED, } +SERVICE_TASK_TYPE_MAP = { + SERVICE_UPDATE_REWARD: TaskType.REWARD, + SERVICE_CREATE_REWARD: TaskType.REWARD, + SERVICE_UPDATE_HABIT: TaskType.HABIT, +} + def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: """Return config entry or raise if not found or not loaded.""" @@ -551,12 +570,12 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 return result - async def create_or_update_task(call: ServiceCall) -> ServiceResponse: + async def create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa: C901 """Create or update task action.""" entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) coordinator = entry.runtime_data await coordinator.async_refresh() - is_update = call.service == SERVICE_UPDATE_REWARD + is_update = call.service in (SERVICE_UPDATE_REWARD, SERVICE_UPDATE_HABIT) current_task = None if is_update: @@ -565,7 +584,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 task for task in coordinator.data.tasks if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) - and task.Type is TaskType.REWARD + and task.Type is SERVICE_TASK_TYPE_MAP[call.service] ) except StopIteration as e: raise ServiceValidationError( @@ -648,6 +667,22 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 if (cost := call.data.get(ATTR_COST)) is not None: data["value"] = cost + if priority := call.data.get(ATTR_PRIORITY): + data["priority"] = TaskPriority[priority] + + if frequency := call.data.get(ATTR_FREQUENCY): + data["frequency"] = frequency + + if up_down := call.data.get(ATTR_UP_DOWN): + data["up"] = "up" in up_down + data["down"] = "down" in up_down + + if counter_up := call.data.get(ATTR_COUNTER_UP): + data["counterUp"] = counter_up + + if counter_down := call.data.get(ATTR_COUNTER_DOWN): + data["counterDown"] = counter_down + try: if is_update: if TYPE_CHECKING: @@ -684,6 +719,13 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 schema=SERVICE_UPDATE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) + hass.services.async_register( + DOMAIN, + SERVICE_UPDATE_HABIT, + create_or_update_task, + schema=SERVICE_UPDATE_TASK_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) hass.services.async_register( DOMAIN, SERVICE_CREATE_REWARD, diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index b92b765e18c..f5a9c2b0032 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -144,7 +144,7 @@ update_reward: fields: config_entry: *config_entry task: *task - rename: + rename: &rename selector: text: notes: ¬es @@ -160,7 +160,7 @@ update_reward: step: 0.01 unit_of_measurement: "🪙" mode: box - tag_options: + tag_options: &tag_options collapsed: true fields: tag: &tag @@ -176,7 +176,7 @@ update_reward: developer_options: &developer_options collapsed: true fields: - alias: + alias: &alias required: false selector: text: @@ -193,3 +193,62 @@ create_reward: selector: *cost_selector tag: *tag developer_options: *developer_options +update_habit: + fields: + config_entry: *config_entry + task: *task + rename: *rename + notes: *notes + up_down: + required: false + selector: + select: + options: + - value: up + label: "âž•" + - value: down + label: "âž–" + multiple: true + mode: list + priority: + required: false + selector: + select: + options: + - "trivial" + - "easy" + - "medium" + - "hard" + mode: dropdown + translation_key: "priority" + frequency: + required: false + selector: + select: + options: + - "daily" + - "weekly" + - "monthly" + translation_key: "frequency" + mode: dropdown + tag_options: *tag_options + developer_options: + collapsed: true + fields: + counter_up: + required: false + selector: + number: + min: 0 + step: 1 + unit_of_measurement: "âž•" + mode: box + counter_down: + required: false + selector: + number: + min: 0 + step: 1 + unit_of_measurement: "âž–" + mode: box + alias: *alias diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 0658e594d07..22ea44351da 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -759,6 +759,70 @@ "description": "[%key:component::habitica::common::developer_options_description%]" } } + }, + "update_habit": { + "name": "Update a habit", + "description": "Updates a specific habit for the selected Habitica character", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Select the Habitica account to update a habit." + }, + "task": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "[%key:component::habitica::common::task_description%]" + }, + "rename": { + "name": "[%key:component::habitica::common::rename_name%]", + "description": "[%key:component::habitica::common::rename_description%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "remove_tag": { + "name": "[%key:component::habitica::common::remove_tag_name%]", + "description": "[%key:component::habitica::common::remove_tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "priority": { + "name": "Difficulty", + "description": "Update the difficulty of a task." + }, + "frequency": { + "name": "Counter reset", + "description": "Update when a habit's counter resets: daily resets at the start of a new day, weekly after Sunday night, and monthly at the beginning of a new month." + }, + "up_down": { + "name": "Rewards or losses", + "description": "Update if the habit is good and rewarding (positive), bad and penalizing (negative), or both." + }, + "counter_up": { + "name": "Adjust positive counter", + "description": "Update the up counter of a positive habit." + }, + "counter_down": { + "name": "Adjust negative counter", + "description": "Update the down counter of a negative habit." + } + }, + "sections": { + "tag_options": { + "name": "[%key:component::habitica::common::tag_options_name%]", + "description": "[%key:component::habitica::common::tag_options_description%]" + }, + "developer_options": { + "name": "[%key:component::habitica::common::developer_options_name%]", + "description": "[%key:component::habitica::common::developer_options_description%]" + } + } } }, "selector": { @@ -793,6 +857,14 @@ "medium": "Medium", "hard": "Hard" } + }, + "frequency": { + "options": { + "daily": "Daily", + "weekly": "Weekly", + "monthly": "Monthly", + "yearly": "Yearly" + } } } } diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 0b25dc4385e..10a8bc0a588 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -6,7 +6,15 @@ from unittest.mock import AsyncMock, patch from uuid import UUID from aiohttp import ClientError -from habiticalib import Direction, HabiticaTaskResponse, Skill, Task, TaskType +from habiticalib import ( + Direction, + Frequency, + HabiticaTaskResponse, + Skill, + Task, + TaskPriority, + TaskType, +) import pytest from syrupy.assertion import SnapshotAssertion @@ -14,7 +22,10 @@ from homeassistant.components.habitica.const import ( ATTR_ALIAS, ATTR_CONFIG_ENTRY, ATTR_COST, + ATTR_COUNTER_DOWN, + ATTR_COUNTER_UP, ATTR_DIRECTION, + ATTR_FREQUENCY, ATTR_ITEM, ATTR_KEYWORD, ATTR_NOTES, @@ -25,6 +36,7 @@ from homeassistant.components.habitica.const import ( ATTR_TARGET, ATTR_TASK, ATTR_TYPE, + ATTR_UP_DOWN, DOMAIN, SERVICE_ABORT_QUEST, SERVICE_ACCEPT_QUEST, @@ -38,6 +50,7 @@ from homeassistant.components.habitica.const import ( SERVICE_SCORE_REWARD, SERVICE_START_QUEST, SERVICE_TRANSFORMATION, + SERVICE_UPDATE_HABIT, SERVICE_UPDATE_REWARD, ) from homeassistant.components.todo import ATTR_RENAME @@ -919,6 +932,13 @@ async def test_get_tasks( ), ], ) +@pytest.mark.parametrize( + ("service", "task_id"), + [ + (SERVICE_UPDATE_REWARD, "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"), + (SERVICE_UPDATE_HABIT, "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a"), + ], +) @pytest.mark.usefixtures("habitica") async def test_update_task_exceptions( hass: HomeAssistant, @@ -927,15 +947,16 @@ async def test_update_task_exceptions( exception: Exception, expected_exception: Exception, exception_msg: str, + service: str, + task_id: str, ) -> None: """Test Habitica task action exceptions.""" - task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" habitica.update_task.side_effect = exception with pytest.raises(expected_exception, match=exception_msg): await hass.services.async_call( DOMAIN, - SERVICE_UPDATE_REWARD, + service, service_data={ ATTR_CONFIG_ENTRY: config_entry.entry_id, ATTR_TASK: task_id, @@ -1125,6 +1146,90 @@ async def test_create_reward( habitica.create_task.assert_awaited_with(call_args) +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_RENAME: "RENAME", + }, + Task(text="RENAME"), + ), + ( + { + ATTR_NOTES: "NOTES", + }, + Task(notes="NOTES"), + ), + ( + { + ATTR_UP_DOWN: [""], + }, + Task(up=False, down=False), + ), + ( + { + ATTR_UP_DOWN: ["up"], + }, + Task(up=True, down=False), + ), + ( + { + ATTR_UP_DOWN: ["down"], + }, + Task(up=False, down=True), + ), + ( + { + ATTR_PRIORITY: "trivial", + }, + Task(priority=TaskPriority.TRIVIAL), + ), + ( + { + ATTR_FREQUENCY: "daily", + }, + Task(frequency=Frequency.DAILY), + ), + ( + { + ATTR_COUNTER_UP: 1, + ATTR_COUNTER_DOWN: 2, + }, + Task(counterUp=1, counterDown=2), + ), + ( + { + ATTR_ALIAS: "ALIAS", + }, + Task(alias="ALIAS"), + ), + ], +) +async def test_update_habit( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica habit action.""" + task_id = "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_HABIT, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.update_task.assert_awaited_with(UUID(task_id), call_args) + + async def test_tags( hass: HomeAssistant, config_entry: MockConfigEntry,