diff --git a/README.md b/README.md index 5e4ad30..7ce1f71 100644 --- a/README.md +++ b/README.md @@ -119,14 +119,14 @@ Access task settings through the cog icon ⚙️ on the integration page. | Option | Description | |---------------------------|-----------------------------------------------------------------------------------------------------------------| | **Active** | Pause tasks when disabled (sensor shows `inactive` state) | -| **Active Override** | Select an `input_boolean` helper to override the Active setting at runtime | +| **Active Override** | Select an `input_boolean` or `binary_sensor` helper to override the Active setting at runtime | | **Icon** | Choose an icon for the sensor (available as attribute for notifications) | | **Tags** | Add keywords for filtering in automations/templates (e.g., assignees, notification times) | | **Todo Lists** | Select Todo lists for automatic task addition when due or due soon | | **Due Soon** | Number of days before due date when the sensor switches to `due_soon` state and the task is added to todo lists | | **Due Soon Override** | Select an `input_number` helper (value in days) to override the Due Soon threshold at runtime | | **Notification Interval** | Reference value for automation/template notification timing | -| **Dependencies** | Select other Task Tracker sensors this task depends on | +| **Dependencies** | Select other Task Tracker sensors this task depends on | **Options specific to *Repeat after completion*:** diff --git a/__init__.py b/__init__.py index 68e4adc..f0e4b9e 100644 --- a/__init__.py +++ b/__init__.py @@ -3,16 +3,21 @@ from __future__ import annotations import logging +import pathlib import voluptuous as vol +from homeassistant.components import blueprint +from homeassistant.components.automation.helpers import async_get_blueprints +from homeassistant.components.blueprint.const import BLUEPRINT_FOLDER from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform, CONF_ICON, CONF_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ServiceValidationError, HomeAssistantError -from homeassistant.helpers import entity_registry, config_validation as cv +from homeassistant.helpers import entity_registry from homeassistant.helpers.event import async_track_time_change from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util +from homeassistant.util.yaml import load_yaml_dict from .const import DOMAIN, CONF_TASK_INTERVAL_VALUE, CONF_DAY, CONF_TASK_INTERVAL_TYPE, CONF_NOTIFICATION_INTERVAL, \ CONF_DUE_SOON_DAYS, CONF_DUE_SOON_OVERRIDE, CONF_TAGS, CONF_ACTIVE, CONF_TODO_LISTS, SERVICE_MARK_AS_DONE, \ @@ -103,9 +108,30 @@ async def async_set_last_done_date(service_call: ServiceCall): cards = TaskTrackerCardRegistration(hass) await cards.async_register(show_panel=show_panel) + await _async_register_blueprints(hass) + return True +async def _async_register_blueprints(hass: HomeAssistant) -> None: + """Register bundled automation blueprints with Home Assistant's blueprint manager.""" + domain_blueprints = async_get_blueprints(hass) + blueprint_file = pathlib.Path(__file__).parent / BLUEPRINT_FOLDER / "task_tracker_notify.yaml" + blueprint_rel_path = f"{DOMAIN}/{blueprint_file.name}" + try: + bp_data = await hass.async_add_executor_job(load_yaml_dict, blueprint_file) + bp = blueprint.Blueprint( + bp_data, + expected_domain="automation", + path=blueprint_rel_path, + schema=domain_blueprints._blueprint_schema, + ) + await domain_blueprints.async_add_blueprint(bp, blueprint_rel_path, allow_override=True) + _LOGGER.debug("Registered blueprint %s", blueprint_rel_path) + except Exception: # noqa: BLE001 + _LOGGER.exception("Failed to register blueprint %s", blueprint_rel_path) + + async def async_update_entities(entity_ids: list[str], hass: HomeAssistant) -> dict | None: """Update the entity.""" try: diff --git a/blueprints/task_tracker_notify.yaml b/blueprints/task_tracker_notify.yaml new file mode 100644 index 0000000..5a3f7ae --- /dev/null +++ b/blueprints/task_tracker_notify.yaml @@ -0,0 +1,107 @@ +blueprint: + name: Task Tracker - Notify about due tasks + description: >- + Send a mobile app notification for each due task at a specified time each day. + Only tasks whose overdue_by value is a multiple of their notification_interval + will trigger a notification, preventing repeated daily alerts. + + The notify action, tags, and language can be customised via the blueprint inputs. + domain: automation + input: + notification_time: + name: Notification time + description: Time of day to check for due tasks and send notifications. + default: "08:00:00" + selector: + time: {} + notify_action: + name: Notify action + description: The notify action to call (e.g. notify.mobile_app_my_phone). + selector: + text: {} + tags: + name: Tags + description: The tags that must be present in a task's tags attribute for the notification to be sent. + default: tag + selector: + text: {} + language: + name: Language + description: Language used for notification messages. + default: en + selector: + language: + languages: + - en + - de + - fr + +triggers: + - trigger: time + at: !input notification_time + +conditions: [] + +actions: + - variables: + language_tmp: !input language + language: > + {{ + language_tmp + if language_tmp is string and language_tmp in ["de", "fr"] + else "en" + }} + mui: + en: + due_since_today: "due today" + due_since_yesterday: "due since yesterday" + due_since_days: "due since %d days" + de: + due_since_today: "heute fällig" + due_since_yesterday: "seit gestern fällig" + due_since_days: "seit %d Tagen fällig" + fr: + due_since_today: "à faire aujourd'hui" + due_since_yesterday: "dû depuis hier" + due_since_days: "dû depuis %d jours" + tasks: |- + {%- set ns = namespace(items=[]) -%} + {%- for entity_id in integration_entities('task_tracker') -%} + {%- if entity_id.startswith('sensor.') and states[entity_id].state == "due" -%} + {%- set overdue_by = states[entity_id].attributes.overdue_by if 'overdue_by' in states[entity_id].attributes else None -%} + {%- set notification_interval = states[entity_id].attributes.notification_interval if 'notification_interval' in states[entity_id].attributes else None -%} + {%- if overdue_by is not none and notification_interval is not none and (overdue_by % notification_interval == 0) -%} + {%- set ns.items = ns.items + [entity_id] -%} + {%- endif -%} + {%- endif -%} + {%- endfor -%} + {%- for item in ns.items -%} + - {{ item }} + {%- endfor -%} + - repeat: + for_each: "{{ tasks }}" + sequence: + - alias: Notify User + if: + - condition: template + value_template: "{{ configured_tags in states[repeat.item].attributes.tags }}" + then: + - action: !input notify_action + data: + title: "{{ states[repeat.item].attributes.friendly_name }}" + message: >- + {%- set overdue_by = states[repeat.item].attributes.overdue_by -%} + {%- if overdue_by == 0 -%} + {{ mui[language].due_since_today }} + {%- elif overdue_by == 1 -%} + {{ mui[language].due_since_yesterday }} + {%- else -%} + {{ mui[language].due_since_days | replace('%d', overdue_by | string) }} + {%- endif -%} + data: + tag: "{{ repeat.item }}" + color: green + group: "{{ repeat.item }}" + notification_icon: "{{ states[repeat.item].attributes.icon }}" + +mode: single diff --git a/manifest.json b/manifest.json index 03cad9f..bd99b93 100644 --- a/manifest.json +++ b/manifest.json @@ -6,7 +6,7 @@ "@gensyn" ], "config_flow": true, - "dependencies": [], + "dependencies": ["blueprint"], "documentation": "https://github.com/gensyn/task_tracker", "homekit": {}, "iot_class": "calculated", diff --git a/tests/unit_tests/homeassistant_mock/homeassistant/components/automation/__init__.py b/tests/unit_tests/homeassistant_mock/homeassistant/components/automation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit_tests/homeassistant_mock/homeassistant/components/automation/helpers.py b/tests/unit_tests/homeassistant_mock/homeassistant/components/automation/helpers.py new file mode 100644 index 0000000..cc15fcd --- /dev/null +++ b/tests/unit_tests/homeassistant_mock/homeassistant/components/automation/helpers.py @@ -0,0 +1,2 @@ +def async_get_blueprints(hass): + return getattr(hass, "automation_blueprints", None) diff --git a/tests/unit_tests/homeassistant_mock/homeassistant/components/blueprint/__init__.py b/tests/unit_tests/homeassistant_mock/homeassistant/components/blueprint/__init__.py new file mode 100644 index 0000000..f53f8d6 --- /dev/null +++ b/tests/unit_tests/homeassistant_mock/homeassistant/components/blueprint/__init__.py @@ -0,0 +1,6 @@ +class Blueprint: + def __init__(self, data, expected_domain, path, schema): + self.data = data + self.expected_domain = expected_domain + self.path = path + self.schema = schema diff --git a/tests/unit_tests/homeassistant_mock/homeassistant/components/blueprint/const.py b/tests/unit_tests/homeassistant_mock/homeassistant/components/blueprint/const.py new file mode 100644 index 0000000..2a194ed --- /dev/null +++ b/tests/unit_tests/homeassistant_mock/homeassistant/components/blueprint/const.py @@ -0,0 +1 @@ +BLUEPRINT_FOLDER = "blueprints" diff --git a/tests/unit_tests/homeassistant_mock/homeassistant/util/yaml.py b/tests/unit_tests/homeassistant_mock/homeassistant/util/yaml.py new file mode 100644 index 0000000..7d661e7 --- /dev/null +++ b/tests/unit_tests/homeassistant_mock/homeassistant/util/yaml.py @@ -0,0 +1,2 @@ +def load_yaml_dict(path): + return {} diff --git a/tests/unit_tests/test_init.py b/tests/unit_tests/test_init.py index 619cabd..76ebb1b 100644 --- a/tests/unit_tests/test_init.py +++ b/tests/unit_tests/test_init.py @@ -1,7 +1,7 @@ import sys import unittest from pathlib import Path -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch absolute_mock_path = str(Path(__file__).parent / "homeassistant_mock") sys.path.insert(0, absolute_mock_path) @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ICON -from task_tracker import async_migrate_entry, _get_coordinator +from task_tracker import _async_register_blueprints, async_migrate_entry, _get_coordinator from task_tracker.const import ( CONF_ACTIVE, CONF_TASK_INTERVAL_VALUE, CONF_TASK_INTERVAL_TYPE, CONF_TAGS, CONF_TODO_LISTS, CONF_DUE_SOON_DAYS, CONF_DUE_SOON_OVERRIDE, @@ -419,3 +419,41 @@ def test_raises_when_coordinator_not_in_hass_data(self): with self.assertRaises(ValueError): _get_coordinator(mock_hass, "sensor.task_tracker_my_task") + +class TestRegisterBlueprints(unittest.IsolatedAsyncioTestCase): + + async def test_registers_bundled_blueprint(self): + mock_hass = MagicMock() + mock_hass.async_add_executor_job = AsyncMock(return_value={"blueprint": {}}) + domain_blueprints = MagicMock() + domain_blueprints._blueprint_schema = {} + domain_blueprints.async_add_blueprint = AsyncMock() + + with ( + patch("task_tracker.async_get_blueprints", return_value=domain_blueprints), + patch("task_tracker.blueprint.Blueprint", return_value=MagicMock()) as mock_blueprint, + ): + await _async_register_blueprints(mock_hass) + + mock_hass.async_add_executor_job.assert_awaited_once() + async_job_args = mock_hass.async_add_executor_job.call_args.args + self.assertGreaterEqual(len(async_job_args), 2) + blueprint_file = async_job_args[1] + self.assertEqual(blueprint_file.name, "task_tracker_notify.yaml") + mock_blueprint.assert_called_once() + domain_blueprints.async_add_blueprint.assert_awaited_once() + + async def test_logs_and_continues_when_blueprint_registration_fails(self): + mock_hass = MagicMock() + mock_hass.async_add_executor_job = AsyncMock(side_effect=RuntimeError("boom")) + domain_blueprints = MagicMock() + domain_blueprints._blueprint_schema = {} + + with ( + patch("task_tracker.async_get_blueprints", return_value=domain_blueprints), + patch("task_tracker._LOGGER.exception") as mock_exception, + ): + await _async_register_blueprints(mock_hass) + + mock_exception.assert_called_once() + domain_blueprints.async_add_blueprint.assert_not_called()