Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*:**

Expand Down
28 changes: 27 additions & 1 deletion __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, \
Expand Down Expand Up @@ -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)

Comment on lines 108 to +112

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Please move the imports from _async_register_blueprints(hass: HomeAssistant) to the top and instead add unit test coverage for the function.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in b5a55d6. I moved the _async_register_blueprints imports to module top-level and added unit test coverage for _async_register_blueprints success and failure paths in tests/unit_tests/test_init.py.

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,
)
Comment thread
gensyn marked this conversation as resolved.
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:
Expand Down
107 changes: 107 additions & 0 deletions blueprints/task_tracker_notify.yaml
Original file line number Diff line number Diff line change
@@ -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:
Comment thread
gensyn marked this conversation as resolved.
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
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"@gensyn"
],
"config_flow": true,
"dependencies": [],
"dependencies": ["blueprint"],
"documentation": "https://github.com/gensyn/task_tracker",
"homekit": {},
"iot_class": "calculated",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
def async_get_blueprints(hass):
return getattr(hass, "automation_blueprints", None)
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
BLUEPRINT_FOLDER = "blueprints"
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
def load_yaml_dict(path):
return {}
42 changes: 40 additions & 2 deletions tests/unit_tests/test_init.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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()
Loading