456 files

This commit is contained in:
Home Assistant Version Control
2026-10-01 05:38:21 +00:00
parent 4192ecbaae
commit 5d6d845ebf
456 changed files with 64951 additions and 3828 deletions
+327 -6
View File
@@ -63,6 +63,10 @@ from .const import (
ATTR_REWARD_ID,
ATTR_SOUND,
BADGE_TIERS,
BOUNTY_CLAIM_HOURS_MAX,
BOUNTY_DESCRIPTION_MAX_LENGTH,
BOUNTY_POINTS_MAX,
BOUNTY_TITLE_MAX_LENGTH,
CHORE_SUGGESTED_POINTS_MAX,
CONF_TASK_GROUP_CHORE_IDS,
CONF_TASK_GROUP_ID,
@@ -77,6 +81,7 @@ from .const import (
SERVICE_ADD_PENALTY,
SERVICE_ADD_POINTS,
SERVICE_ADD_TASK_GROUP,
SERVICE_ADJUST_STREAK_FREEZES,
SERVICE_ALLOCATE_POINTS_TO_POOL,
SERVICE_APPLY_BONUS,
SERVICE_APPLY_MANDATORY_PENALTY,
@@ -85,12 +90,18 @@ from .const import (
SERVICE_APPROVE_CHORE,
SERVICE_APPROVE_REWARD,
SERVICE_CHOOSE_AVATAR,
SERVICE_CLAIM_BOUNTY,
SERVICE_CLAIM_REWARD,
SERVICE_COMPLETE_BONUS_SUBTASK,
SERVICE_COMPLETE_BOUNTY,
SERVICE_COMPLETE_CHORE,
SERVICE_COMPLETE_NEXT_CHORE,
SERVICE_DISMISS_MANDATORY_CHORE,
SERVICE_GIFT_POINTS,
SERVICE_GIVE_BACK_BOUNTY,
SERVICE_LEAVE_TEAM_CHORE,
SERVICE_PAUSE_TIMED_TASK,
SERVICE_POST_BOUNTY,
SERVICE_POSTPONE_MANDATORY_CHORE,
SERVICE_PREVIEW_SOUND,
SERVICE_READ_ALOUD,
@@ -98,6 +109,7 @@ from .const import (
SERVICE_REJECT_CHORE,
SERVICE_REJECT_REWARD,
SERVICE_REMOVE_BONUS,
SERVICE_REMOVE_BOUNTY,
SERVICE_REMOVE_PENALTY,
SERVICE_REMOVE_POINTS,
SERVICE_REMOVE_TASK_GROUP,
@@ -109,17 +121,21 @@ from .const import (
SERVICE_START_TIMED_TASK,
SERVICE_STOP_TIMED_TASK,
SERVICE_TEST_NOTIFICATION,
SERVICE_UNDO_CHORE,
SERVICE_UNDO_CHORE_APPROVAL,
SERVICE_UNDO_TRANSACTION,
SERVICE_UPDATE_BONUS,
SERVICE_UPDATE_BOUNTY,
SERVICE_UPDATE_PENALTY,
SERVICE_UPDATE_TASK_GROUP,
TASK_GROUP_POLICIES,
TEAM_POINTS_MODES,
TEAM_SIZE_MAX,
TIME_CATEGORIES,
)
from .coordinator import TaskMateCoordinator
from .frontend import async_register_cards, async_register_frontend
from .models import Badge, BadgeCriterion
from .models import REJECT_REASON_MAX, Badge, BadgeCriterion
from .panel import async_register_panel
from .websocket import async_register_websocket_commands
@@ -191,6 +207,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Entities of children deleted before #946 stayed in the registry. Never
# let the sweep block setup.
try:
coordinator.async_prune_orphan_child_entities()
except Exception: # noqa: BLE001
_LOGGER.exception("Could not prune entities of deleted children")
# Likewise the buttons of chores/rewards deleted before #960.
try:
coordinator.async_prune_orphan_button_entities()
except Exception: # noqa: BLE001
_LOGGER.exception("Could not prune buttons of deleted chores or rewards")
# Register services (only once)
if not hass.data[DOMAIN].get(SERVICES_REGISTERED):
await _async_register_services(hass)
@@ -242,6 +270,7 @@ _DYNAMIC_SELECTOR_FIELDS: dict[str, str] = {
"penalty_id": "get_penalties",
"bonus_id": "get_bonuses",
"group_id": "get_task_groups",
"wish_id": "get_wishes",
}
_BASE_SERVICE_DESCRIPTIONS: dict | None = None
@@ -348,6 +377,9 @@ _AUDIT_TARGET_KEYS = (
"claim_id",
"transaction_id",
"type_id",
"wish_id",
"bounty_id",
"inspection_id",
)
@@ -538,6 +570,114 @@ async def _async_register_services(hass: HomeAssistant) -> None:
# validation error rather than an unhandled 500 + traceback.
raise ServiceValidationError(str(err)) from err
async def handle_complete_next_chore(call: ServiceCall) -> None:
"""Complete the child's next outstanding chore (#978), e.g. from a watch.
"Next" is the child card's order (see watch.next_chore_for_child); chores
that need a photo, a note, a timer or a team are skipped. The completion
goes through the normal path, so approval rules apply exactly as if the
child had tapped it. Nothing left to do is a quiet no-op, not an error —
a watch button pressed once too often shouldn't raise an alert.
"""
from .watch import next_chore_for_child
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
child_id = call.data[ATTR_CHILD_ID]
# The same gate as a child completing a chore for themselves.
await _async_require_linked_child(hass, call, coordinator, child_id)
if coordinator.get_child(child_id) is None:
raise ServiceValidationError(f"Child {child_id} not found")
chore = next_chore_for_child(coordinator, child_id)
if chore is None:
_LOGGER.debug("complete_next_chore: nothing left to complete for %s", child_id)
return
await coordinator.async_complete_chore(chore.id, child_id)
async def handle_leave_team_chore(call: ServiceCall) -> None:
"""Take a child back out of a teamwork chore they joined (#928)."""
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
child_id = call.data[ATTR_CHILD_ID]
# Same gate as completing: a child acts for themselves, parents for anyone.
await _async_require_linked_child(hass, call, coordinator, child_id)
await coordinator.async_leave_team_chore(call.data[ATTR_CHORE_ID], child_id)
# ── Bounty board (#931) ───────────────────────────────────────────────
_BOUNTY_FIELDS = (
"title",
"points",
"description",
"icon",
"expires_at",
"eligible_child_ids",
"claim_hours",
"require_photo",
"notify_children",
)
async def handle_post_bounty(call: ServiceCall) -> None:
"""A parent posts a one-off job to the bounty board."""
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
extra = {k: call.data[k] for k in _BOUNTY_FIELDS if k in call.data and k not in ("title", "points")}
await coordinator.async_post_bounty(call.data["title"], call.data["points"], **extra)
async def handle_update_bounty(call: ServiceCall) -> None:
"""A parent edits a bounty (only points and expiry once it's claimed)."""
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
changes = {k: call.data[k] for k in _BOUNTY_FIELDS if k in call.data}
await coordinator.async_update_bounty(call.data["bounty_id"], **changes)
async def handle_remove_bounty(call: ServiceCall) -> None:
"""A parent takes a bounty off the board."""
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
await coordinator.async_remove_bounty(call.data["bounty_id"])
async def handle_claim_bounty(call: ServiceCall) -> None:
"""A child claims a bounty — same linked-child gate as completing a chore."""
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
child_id = call.data[ATTR_CHILD_ID]
await _async_require_linked_child(hass, call, coordinator, child_id)
await coordinator.async_claim_bounty(call.data["bounty_id"], child_id)
async def handle_give_back_bounty(call: ServiceCall) -> None:
"""The claimer hands a bounty back to the board."""
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
child_id = call.data[ATTR_CHILD_ID]
await _async_require_linked_child(hass, call, coordinator, child_id)
await coordinator.async_give_back_bounty(call.data["bounty_id"], child_id)
async def handle_complete_bounty(call: ServiceCall) -> None:
"""The claimer marks their bounty done — it goes to the approval queue."""
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
child_id = call.data[ATTR_CHILD_ID]
await _async_require_linked_child(hass, call, coordinator, child_id)
await coordinator.async_complete_bounty(
call.data["bounty_id"], child_id, photo_url=call.data.get("photo_url", "")
)
async def handle_complete_bonus_subtask(call: ServiceCall) -> None:
"""Handle the complete_bonus_subtask service call."""
coordinator = _get_coordinator(hass)
@@ -584,7 +724,9 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_LOGGER.error("No TaskMate coordinator available")
return
completion_id = call.data["completion_id"]
await coordinator.async_approve_chore(completion_id, points=call.data.get("points"))
await coordinator.async_approve_chore(
completion_id, points=call.data.get("points"), rating=call.data.get("rating")
)
async def handle_approve_all_chores(call: ServiceCall) -> None:
"""Handle the approve_all_chores service call."""
@@ -592,7 +734,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
await coordinator.async_approve_chores_bulk(call.data.get("completion_ids"))
await coordinator.async_approve_chores_bulk(call.data.get("completion_ids"), rating=call.data.get("rating"))
async def handle_reject_chore(call: ServiceCall) -> None:
"""Handle the reject_chore service call."""
@@ -601,7 +743,26 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_LOGGER.error("No TaskMate coordinator available")
return
completion_id = call.data["completion_id"]
await coordinator.async_reject_chore(completion_id)
await coordinator.async_reject_chore(completion_id, reason=call.data.get("reason", ""))
async def handle_undo_chore(call: ServiceCall) -> None:
"""A child takes back their own chore inside the undo window (#918).
The child is read from the stored completion, never from the call, and
the linked-child rule is applied to that child — so one child can't
undo a sibling's chore. The window and parent-review checks live in the
coordinator. Parents keep reject_chore / undo_chore_approval.
"""
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
completion_id = call.data["completion_id"]
completion = next((c for c in coordinator.storage.get_completions() if c.id == completion_id), None)
if completion is None:
raise ValueError("This chore has already been undone. Refresh the card.")
await _async_require_linked_child(hass, call, coordinator, completion.child_id)
await coordinator.async_undo_chore(completion_id)
async def handle_apply_mandatory_penalty(call: ServiceCall) -> None:
"""Handle apply_mandatory_penalty (deduct penalty for a missed mandatory chore)."""
@@ -663,6 +824,14 @@ async def _async_register_services(hass: HomeAssistant) -> None:
call.data["points"],
)
async def handle_adjust_streak_freezes(call: ServiceCall) -> None:
"""Grant (positive amount) or remove (negative) streak-freeze tokens (#925)."""
coordinator = _get_coordinator(hass)
if not coordinator:
_LOGGER.error("No TaskMate coordinator available")
return
await coordinator.async_adjust_streak_freezes(call.data[ATTR_CHILD_ID], call.data["amount"])
async def handle_record_allowance_payout(call: ServiceCall) -> None:
"""Record a parent-confirmed allowance payout (deduct points, log cash)."""
coordinator = _get_coordinator(hass)
@@ -741,7 +910,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_LOGGER.error("No TaskMate coordinator available")
return
claim_id = call.data["claim_id"]
await coordinator.async_reject_reward(claim_id)
await coordinator.async_reject_reward(claim_id, reason=call.data.get("reason", ""))
async def handle_claim_reward(call: ServiceCall) -> None:
"""Handle the claim_reward service call."""
@@ -1004,6 +1173,9 @@ async def _async_register_services(hass: HomeAssistant) -> None:
schedule_mode=schedule_mode,
deadline_at=deadline_at,
speed_bonus_points=call.data.get(ATTR_CHORE_SPEED_BONUS_POINTS, 0),
team_size=call.data.get("team_size", 0),
team_points_mode=call.data.get("team_points_mode", "each"),
team_bonus=call.data.get("team_bonus", 0),
)
async def handle_add_badge(call: ServiceCall) -> None:
@@ -1138,6 +1310,13 @@ async def _async_register_services(hass: HomeAssistant) -> None:
),
)
hass.services.async_register(
DOMAIN,
SERVICE_COMPLETE_NEXT_CHORE,
_audited(handle_complete_next_chore),
schema=vol.Schema({vol.Required(ATTR_CHILD_ID): cv.string}),
)
hass.services.async_register(
DOMAIN,
SERVICE_COMPLETE_BONUS_SUBTASK,
@@ -1151,6 +1330,79 @@ async def _async_register_services(hass: HomeAssistant) -> None:
),
)
hass.services.async_register(
DOMAIN,
SERVICE_LEAVE_TEAM_CHORE,
_audited(handle_leave_team_chore),
schema=vol.Schema(
{
vol.Required(ATTR_CHORE_ID): cv.string,
vol.Required(ATTR_CHILD_ID): cv.string,
}
),
)
_bounty_editable = {
vol.Optional("description"): vol.All(cv.string, vol.Length(max=BOUNTY_DESCRIPTION_MAX_LENGTH)),
vol.Optional("icon"): cv.string,
# ISO date-time; "" clears it. A naive time is read as local time.
vol.Optional("expires_at"): vol.Any(None, cv.string),
vol.Optional("eligible_child_ids"): vol.All(cv.ensure_list, [cv.string]),
vol.Optional("claim_hours"): vol.All(vol.Coerce(int), vol.Range(min=1, max=BOUNTY_CLAIM_HOURS_MAX)),
vol.Optional("require_photo"): cv.boolean,
vol.Optional("notify_children"): cv.boolean,
}
hass.services.async_register(
DOMAIN,
SERVICE_POST_BOUNTY,
_parent(handle_post_bounty),
schema=vol.Schema(
{
vol.Required("title"): vol.All(cv.string, vol.Length(min=1, max=BOUNTY_TITLE_MAX_LENGTH)),
vol.Required("points"): vol.All(vol.Coerce(int), vol.Range(min=1, max=BOUNTY_POINTS_MAX)),
**_bounty_editable,
}
),
)
hass.services.async_register(
DOMAIN,
SERVICE_UPDATE_BOUNTY,
_parent(handle_update_bounty),
schema=vol.Schema(
{
vol.Required("bounty_id"): cv.string,
vol.Optional("title"): vol.All(cv.string, vol.Length(min=1, max=BOUNTY_TITLE_MAX_LENGTH)),
vol.Optional("points"): vol.All(vol.Coerce(int), vol.Range(min=1, max=BOUNTY_POINTS_MAX)),
**_bounty_editable,
}
),
)
hass.services.async_register(
DOMAIN,
SERVICE_REMOVE_BOUNTY,
_parent(handle_remove_bounty),
schema=vol.Schema({vol.Required("bounty_id"): cv.string}),
)
_bounty_child_schema = vol.Schema({vol.Required("bounty_id"): cv.string, vol.Required(ATTR_CHILD_ID): cv.string})
hass.services.async_register(
DOMAIN, SERVICE_CLAIM_BOUNTY, _audited(handle_claim_bounty), schema=_bounty_child_schema
)
hass.services.async_register(
DOMAIN, SERVICE_GIVE_BACK_BOUNTY, _audited(handle_give_back_bounty), schema=_bounty_child_schema
)
hass.services.async_register(
DOMAIN,
SERVICE_COMPLETE_BOUNTY,
_audited(handle_complete_bounty),
schema=vol.Schema(
{
vol.Required("bounty_id"): cv.string,
vol.Required(ATTR_CHILD_ID): cv.string,
vol.Optional("photo_url"): cv.string,
}
),
)
hass.services.async_register(
DOMAIN,
SERVICE_START_TIMED_TASK,
@@ -1195,6 +1447,8 @@ async def _async_register_services(hass: HomeAssistant) -> None:
{
vol.Required("completion_id"): cv.string,
vol.Optional("points"): vol.All(vol.Coerce(int), vol.Range(min=0)),
# 1-3 star quality rating (#927); ignored while the feature is off.
vol.Optional("rating"): vol.All(vol.Coerce(int), vol.Range(min=1, max=3)),
}
),
)
@@ -1206,6 +1460,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
schema=vol.Schema(
{
vol.Optional("completion_ids"): [cv.string],
vol.Optional("rating"): vol.All(vol.Coerce(int), vol.Range(min=1, max=3)),
}
),
)
@@ -1217,10 +1472,19 @@ async def _async_register_services(hass: HomeAssistant) -> None:
schema=vol.Schema(
{
vol.Required("completion_id"): cv.string,
vol.Optional("reason", default=""): vol.All(cv.string, vol.Length(max=REJECT_REASON_MAX)),
}
),
)
# Child-facing: gated on the stored child's linked user, not on parent.
hass.services.async_register(
DOMAIN,
SERVICE_UNDO_CHORE,
_audited(handle_undo_chore),
schema=vol.Schema({vol.Required("completion_id"): cv.string}),
)
_miss_schema = vol.Schema({vol.Required("miss_id"): cv.string})
hass.services.async_register(
DOMAIN,
@@ -1287,6 +1551,18 @@ async def _async_register_services(hass: HomeAssistant) -> None:
),
)
hass.services.async_register(
DOMAIN,
SERVICE_ADJUST_STREAK_FREEZES,
_parent(handle_adjust_streak_freezes),
schema=vol.Schema(
{
vol.Required(ATTR_CHILD_ID): cv.string,
vol.Required("amount"): vol.All(vol.Coerce(int), vol.Range(min=-10, max=10), vol.NotIn([0])),
}
),
)
hass.services.async_register(
DOMAIN,
SERVICE_RECORD_ALLOWANCE_PAYOUT,
@@ -1362,7 +1638,12 @@ async def _async_register_services(hass: HomeAssistant) -> None:
DOMAIN,
SERVICE_REJECT_REWARD,
_parent(handle_reject_reward),
schema=vol.Schema({vol.Required("claim_id"): cv.string}),
schema=vol.Schema(
{
vol.Required("claim_id"): cv.string,
vol.Optional("reason", default=""): vol.All(cv.string, vol.Length(max=REJECT_REASON_MAX)),
}
),
)
hass.services.async_register(
@@ -1554,6 +1835,12 @@ async def _async_register_services(hass: HomeAssistant) -> None:
vol.Optional(ATTR_CHORE_REQUIRES_APPROVAL, default=True): cv.boolean,
vol.Optional(ATTR_CHORE_EXPIRES_IN_MINUTES, default=0): vol.All(cv.positive_int, vol.Range(max=10080)),
vol.Optional(ATTR_CHORE_SPEED_BONUS_POINTS, default=0): cv.positive_int,
# Teamwork (#928). The service creates "everyone" chores, so the
# only cross-field rule left to check is the size range, which
# async_add_chore enforces.
vol.Optional("team_size", default=0): vol.All(vol.Coerce(int), vol.Range(min=0, max=TEAM_SIZE_MAX)),
vol.Optional("team_points_mode", default="each"): vol.In(TEAM_POINTS_MODES),
vol.Optional("team_bonus", default=0): cv.positive_int,
}
),
)
@@ -1684,16 +1971,38 @@ async def _async_register_services(hass: HomeAssistant) -> None:
schema=vol.Schema({}),
)
# Wishlist (#932): registered from its own module, through these same gates.
from .wishlist_services import async_register_wishlist_services
async_register_wishlist_services(
hass,
child_action=_audited,
parent_action=_parent,
get_coordinator=lambda: _get_coordinator(hass),
require_linked_child=lambda *args: _async_require_linked_child(*args),
)
# Surprise inspections (#981): parent actions, same gate and audit trail.
from .inspection_services import async_register_inspection_services
async_register_inspection_services(
hass,
parent_action=_parent,
get_coordinator=lambda: _get_coordinator(hass),
)
def _async_unregister_services(hass: HomeAssistant) -> None:
"""Unregister TaskMate services."""
services = [
SERVICE_COMPLETE_CHORE,
SERVICE_COMPLETE_NEXT_CHORE,
SERVICE_COMPLETE_BONUS_SUBTASK,
SERVICE_APPROVE_CHORE,
SERVICE_APPROVE_ALL_CHORES,
SERVICE_REJECT_CHORE,
SERVICE_UNDO_TRANSACTION,
SERVICE_UNDO_CHORE,
SERVICE_UNDO_CHORE_APPROVAL,
SERVICE_TEST_NOTIFICATION,
SERVICE_GIFT_POINTS,
@@ -1722,6 +2031,13 @@ def _async_unregister_services(hass: HomeAssistant) -> None:
SERVICE_ADD_TASK_GROUP,
SERVICE_UPDATE_TASK_GROUP,
SERVICE_REMOVE_TASK_GROUP,
SERVICE_LEAVE_TEAM_CHORE,
SERVICE_POST_BOUNTY,
SERVICE_UPDATE_BOUNTY,
SERVICE_REMOVE_BOUNTY,
SERVICE_CLAIM_BOUNTY,
SERVICE_GIVE_BACK_BOUNTY,
SERVICE_COMPLETE_BOUNTY,
SERVICE_START_TIMED_TASK,
SERVICE_PAUSE_TIMED_TASK,
SERVICE_STOP_TIMED_TASK,
@@ -1732,5 +2048,10 @@ def _async_unregister_services(hass: HomeAssistant) -> None:
"revoke_badge",
"rebuild_badges",
]
from .inspection_services import INSPECTION_SERVICES
from .wishlist_services import WISHLIST_SERVICES
services.extend(WISHLIST_SERVICES)
services.extend(INSPECTION_SERVICES)
for service in services:
hass.services.async_remove(DOMAIN, service)