Skip to content

Commit a704740

Browse files
committed
Add service to reset last complete
1 parent 33192ab commit a704740

File tree

13 files changed

+514
-29
lines changed

13 files changed

+514
-29
lines changed

.storage/maint.store

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"version": 2,
3+
"minor_version": 1,
4+
"key": "maint.store",
5+
"data": {
6+
"entries": {
7+
"entry": [
8+
{
9+
"task_id": "40ab9650c1ca40458c4fdb0e9ac91f64",
10+
"description": "Test",
11+
"last_completed": "2024-02-02",
12+
"recurrence": {
13+
"type": "interval",
14+
"every": 7,
15+
"unit": "days"
16+
}
17+
}
18+
]
19+
}
20+
}
21+
}

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ events when tasks become due so you can automate reminders or actions.
1212
- Flexible schedules: intervals (every N days/weeks/months) and custom weekly patterns.
1313
- Event `maint_task_due` fired when a task’s binary sensor turns on, including task metadata for use
1414
in automations.
15+
- Service `maint.reset_last_completed` to mark a task as completed (defaults to today, or provide a
16+
specific date).
1517

1618
## Install
1719

@@ -23,6 +25,12 @@ events when tasks become due so you can automate reminders or actions.
2325
3) Find **Maint** under HACS Integrations and install it.
2426
4) Restart Home Assistant, then add the Maint integration from *Settings → Devices & Services*.
2527

28+
## Service
29+
30+
Call the `maint.reset_last_completed` service to mark a task complete. Target a Maint binary sensor
31+
entity or pass `entry_id` and `task_id`, and optionally include `last_completed` to backdate the
32+
completion (defaults to today).
33+
2634
## What's next
2735
- Publish Maint to the HACS default registry so it can be installed without adding a custom
2836
repository.

custom_components/maint/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from .domain import DOMAIN
1313
from .models import MaintConfigEntry, MaintRuntimeData, MaintTaskStore
1414
from .panel import async_register_panel, async_unregister_panel
15+
from .services import async_register_services
1516
from .websocket import async_register_websocket_handlers
1617

1718
# We only support setup from the UI so we need to load a config entry only schema.
@@ -40,6 +41,7 @@ async def async_setup(hass: HomeAssistant, _config: ConfigType) -> bool:
4041
data: dict[str, Any] = hass.data.setdefault(DOMAIN, {})
4142
_LOGGER.info("Setting up Maint integration")
4243
await _async_get_task_store(hass)
44+
async_register_services(hass, _async_get_task_store)
4345
await async_register_panel(hass)
4446
if not data.get(DATA_KEY_WS_REGISTERED):
4547
async_register_websocket_handlers(hass)
@@ -90,6 +92,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
9092
return unload_ok
9193

9294

95+
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
96+
"""Remove a config entry and purge its tasks."""
97+
_LOGGER.debug("Removing Maint config entry %s; purging tasks", entry.entry_id)
98+
store = await _async_get_task_store(hass)
99+
await store.async_remove_entry(entry.entry_id)
100+
101+
93102
async def _async_get_task_store(hass: HomeAssistant) -> MaintTaskStore:
94103
"""Return the shared Maint task store."""
95104
data: dict[str, Any] = hass.data.setdefault(DOMAIN, {})

custom_components/maint/frontend/dist/main.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

custom_components/maint/frontend/dist/main.js.map

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

custom_components/maint/frontend/src/formatting.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@ const parseIsoDate = (value: string): Date | null => {
1010
if ([year, month, day].some((part) => Number.isNaN(part))) {
1111
return null;
1212
}
13-
return new Date(Date.UTC(year, month - 1, day));
13+
// Use local time to avoid UTC offsets shifting the displayed day.
14+
return new Date(year, month - 1, day);
1415
};
1516

1617
const formatIsoDate = (value: Date): string =>
17-
`${value.getUTCFullYear().toString().padStart(4, "0")}-${(value.getUTCMonth() + 1)
18+
`${value.getFullYear().toString().padStart(4, "0")}-${(value.getMonth() + 1)
1819
.toString()
19-
.padStart(2, "0")}-${value.getUTCDate().toString().padStart(2, "0")}`;
20+
.padStart(2, "0")}-${value.getDate().toString().padStart(2, "0")}`;
2021

2122
export const parseDate = (value: string | FormDataEntryValue | null | undefined): string | null => {
2223
if (value === null || value === undefined) {

custom_components/maint/models.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,33 @@ async def async_update_task(
371371
)
372372
return task
373373

374+
async def async_set_last_completed(
375+
self,
376+
entry_id: str,
377+
task_id: str,
378+
*,
379+
last_completed: date,
380+
) -> MaintTask:
381+
"""Update only the last completed date for a task."""
382+
self.validate(
383+
entry_id=entry_id,
384+
task_id=task_id,
385+
last_completed=last_completed,
386+
)
387+
388+
tasks = await self._async_get_entry_tasks(entry_id)
389+
task = tasks[task_id]
390+
task.last_completed = last_completed
391+
await self._async_save()
392+
async_dispatcher_send(self._hass, SIGNAL_TASK_UPDATED, entry_id, task)
393+
_LOGGER.debug(
394+
"Updated last completed for Maint task %s for entry %s to %s",
395+
task_id,
396+
entry_id,
397+
task.last_completed,
398+
)
399+
return task
400+
374401
async def async_delete_task(self, entry_id: str, task_id: str) -> MaintTask:
375402
"""Delete and return a task."""
376403
self.validate(entry_id=entry_id, task_id=task_id)
@@ -384,6 +411,22 @@ async def async_delete_task(self, entry_id: str, task_id: str) -> MaintTask:
384411
_LOGGER.debug("Deleted Maint task %s for entry %s", task.task_id, entry_id)
385412
return task
386413

414+
async def async_remove_entry(self, entry_id: str) -> None:
415+
"""Remove all tasks for an entry and persist."""
416+
self.validate(entry_id=entry_id)
417+
await self.async_load()
418+
if entry_id not in self._tasks:
419+
_LOGGER.debug("No Maint tasks to remove for entry %s", entry_id)
420+
return
421+
422+
removed = self._tasks.pop(entry_id)
423+
await self._async_save()
424+
_LOGGER.debug(
425+
"Removed Maint entry %s from task store (%s tasks purged)",
426+
entry_id,
427+
len(removed),
428+
)
429+
387430
async def async_get_task(self, entry_id: str, task_id: str) -> MaintTask:
388431
"""Return a task by id."""
389432
self.validate(entry_id=entry_id, task_id=task_id)
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Maint service registration and handlers."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from datetime import date, datetime
7+
from typing import TYPE_CHECKING, Any
8+
9+
import voluptuous as vol
10+
from homeassistant.exceptions import HomeAssistantError
11+
from homeassistant.helpers import config_validation as cv
12+
from homeassistant.util import dt as dt_util
13+
14+
from .domain import DOMAIN
15+
16+
if TYPE_CHECKING:
17+
from collections.abc import Awaitable, Callable
18+
19+
from homeassistant.core import HomeAssistant, ServiceCall
20+
21+
from .models import MaintTaskStore
22+
23+
SERVICE_RESET_LAST_COMPLETED = "reset_last_completed"
24+
SERVICE_TARGET_GROUP = "task_target"
25+
SERVICE_ENTITY_ID = "entity_id"
26+
SERVICE_ENTRY_ID = "entry_id"
27+
SERVICE_TASK_ID = "task_id"
28+
SERVICE_LAST_COMPLETED = "last_completed"
29+
SERVICE_RESET_LAST_COMPLETED_SCHEMA = vol.Schema(
30+
{
31+
vol.Exclusive(SERVICE_ENTITY_ID, SERVICE_TARGET_GROUP): cv.entity_id,
32+
vol.Inclusive(SERVICE_ENTRY_ID, SERVICE_TARGET_GROUP): cv.string,
33+
vol.Inclusive(SERVICE_TASK_ID, SERVICE_TARGET_GROUP): cv.string,
34+
vol.Optional(SERVICE_LAST_COMPLETED): cv.date,
35+
},
36+
extra=vol.PREVENT_EXTRA,
37+
)
38+
39+
_LOGGER = logging.getLogger(__name__)
40+
41+
DATA_KEY_SERVICES_REGISTERED = "services_registered"
42+
43+
44+
def _normalize_last_completed(value: date | datetime | None) -> date:
45+
"""Convert service input into a date, defaulting to today."""
46+
if value is None:
47+
return dt_util.now().date()
48+
if isinstance(value, datetime):
49+
return dt_util.as_local(value).date()
50+
return value
51+
52+
53+
def _resolve_service_target(
54+
hass: HomeAssistant, data: dict[str, Any]
55+
) -> tuple[str, str]:
56+
"""Return entry and task identifiers from service data."""
57+
if entity_id := data.get(SERVICE_ENTITY_ID):
58+
state = hass.states.get(entity_id)
59+
if state is None:
60+
message = f"Entity {entity_id} not found"
61+
raise HomeAssistantError(message)
62+
entry_id = state.attributes.get("entry_id")
63+
task_id = state.attributes.get("task_id")
64+
if not entry_id or not task_id:
65+
message = f"Entity {entity_id} is not a Maint task sensor"
66+
raise HomeAssistantError(message)
67+
return str(entry_id), str(task_id)
68+
69+
entry_id = data[SERVICE_ENTRY_ID]
70+
task_id = data[SERVICE_TASK_ID]
71+
return str(entry_id), str(task_id)
72+
73+
74+
def async_register_services(
75+
hass: HomeAssistant,
76+
get_task_store: Callable[[HomeAssistant], Awaitable[MaintTaskStore]],
77+
) -> None:
78+
"""Register Maint services."""
79+
data: dict[str, Any] = hass.data.setdefault(DOMAIN, {})
80+
if data.get(DATA_KEY_SERVICES_REGISTERED):
81+
return
82+
83+
async def async_handle_reset_last_completed(call: ServiceCall) -> None:
84+
"""Set a task's last completed date."""
85+
entry_id, task_id = _resolve_service_target(hass, call.data)
86+
last_completed = _normalize_last_completed(
87+
call.data.get(SERVICE_LAST_COMPLETED)
88+
)
89+
store = await get_task_store(hass)
90+
try:
91+
await store.async_set_last_completed(
92+
entry_id, task_id, last_completed=last_completed
93+
)
94+
except KeyError as err:
95+
message = f"Task {task_id} not found for entry {entry_id}"
96+
raise HomeAssistantError(message) from err
97+
98+
hass.services.async_register(
99+
DOMAIN,
100+
SERVICE_RESET_LAST_COMPLETED,
101+
async_handle_reset_last_completed,
102+
schema=SERVICE_RESET_LAST_COMPLETED_SCHEMA,
103+
)
104+
data[DATA_KEY_SERVICES_REGISTERED] = True
105+
_LOGGER.debug("Registered Maint services")
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
reset_last_completed:
2+
name: Reset last completed
3+
description: Set a Maint task's last completed date to today or a provided date.
4+
target:
5+
entity:
6+
integration: maint
7+
domain: binary_sensor
8+
fields:
9+
entity_id:
10+
name: Task entity
11+
description: Binary sensor entity for the Maint task to reset. Use entry_id/task_id instead if the entity is unavailable.
12+
example: binary_sensor.maint_change_air_filter
13+
selector:
14+
entity:
15+
integration: maint
16+
domain: binary_sensor
17+
entry_id:
18+
name: Entry ID
19+
description: Maint config entry ID. Required if entity_id is not provided.
20+
example: "abc123"
21+
task_id:
22+
name: Task ID
23+
description: Maint task ID. Required if entity_id is not provided.
24+
example: "bde3c87c056d4c1b9b22e4fd3160c0f6"
25+
last_completed:
26+
name: Last completed
27+
description: Date to store as the last completed value. Defaults to today when omitted.
28+
example: "2024-05-01"
29+
selector:
30+
date:

custom_components/maint/strings.json

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,37 @@
99
}
1010
}
1111
},
12-
"options": {
13-
"step": {
14-
"init": {
15-
"data": {
16-
"sensor_prefix": "Sensor prefix"
17-
}
12+
"options": {
13+
"step": {
14+
"init": {
15+
"data": {
16+
"sensor_prefix": "Sensor prefix"
17+
}
18+
}
19+
}
20+
},
21+
"services": {
22+
"reset_last_completed": {
23+
"name": "Reset last completed",
24+
"description": "Set a Maint task's last completed date to today or a provided date.",
25+
"fields": {
26+
"entity_id": {
27+
"name": "Task entity",
28+
"description": "Binary sensor entity for the Maint task to reset. Use entry_id/task_id instead if the entity is unavailable."
29+
},
30+
"entry_id": {
31+
"name": "Entry ID",
32+
"description": "Maint config entry ID. Required if entity_id is not provided."
33+
},
34+
"task_id": {
35+
"name": "Task ID",
36+
"description": "Maint task ID. Required if entity_id is not provided."
37+
},
38+
"last_completed": {
39+
"name": "Last completed",
40+
"description": "Date to store as the last completed value. Defaults to today when omitted."
1841
}
1942
}
2043
}
2144
}
45+
}

0 commit comments

Comments
 (0)