This commit is contained in:
Home Assistant Version Control
2026-08-13 19:52:24 +00:00
parent 2d1ba0c035
commit 457f19d210
46 changed files with 3333 additions and 2108 deletions
+73 -27
View File
@@ -1,4 +1,5 @@
"""Assignment operations mixin for TaskMateCoordinator."""
from __future__ import annotations
import asyncio
@@ -118,9 +119,15 @@ class AssignmentsMixin:
await self.storage.async_save()
await self.async_refresh()
_AVAILABLE_STATES: frozenset[str] = frozenset({
"on", "home", "available", "present", "true",
})
_AVAILABLE_STATES: frozenset[str] = frozenset(
{
"on",
"home",
"available",
"present",
"true",
}
)
def _is_visibility_entity_active(
self, visibility_entity: str, visibility_state: str, visibility_operator: str = "equals"
@@ -201,7 +208,7 @@ class AssignmentsMixin:
return True
# Check attributes for a matching value
if hasattr(state_obj, 'attributes') and state_obj.attributes:
if hasattr(state_obj, "attributes") and state_obj.attributes:
for attr_value in state_obj.attributes.values():
if str(attr_value).lower() == parsed_state.lower():
return True
@@ -232,7 +239,8 @@ class AssignmentsMixin:
if state_obj is None or state_obj.state in ("unavailable", "unknown", None, ""):
_LOGGER.debug(
"Weather entity '%s' unavailable, not blocking chore '%s'",
entity_id, getattr(chore, "name", ""),
entity_id,
getattr(chore, "name", ""),
)
return None
@@ -352,6 +360,26 @@ class AssignmentsMixin:
return cached
return self._compute_active_children_uncached(chore, today)
def _swap_override(self, chore: Chore, today: date | None = None) -> str:
"""The child an approved sibling swap moved this chore to for ``today``.
Returns "" when there is no swap for that date, or when the swapped-to
child is no longer in the chore's pool (removed from `assigned_to`, or
deleted). Stamped with a date so it expires on its own — a swap is a
one-day arrangement, and probes of other days must stay pure rotation.
"""
swap_date = getattr(chore, "assignment_swap_date", "") or ""
swapped_to = getattr(chore, "assignment_swap_child_id", "") or ""
if not swap_date or not swapped_to:
return ""
if today is None:
today = dt_util.as_local(dt_util.now()).date()
if swap_date != today.isoformat():
return ""
if swapped_to not in self._chore_assignment_pool(chore):
return ""
return swapped_to
def _compute_active_children_uncached(self, chore: Chore, today: date | None = None) -> list[str]:
mode = getattr(chore, "assignment_mode", "everyone")
require_availability = getattr(chore, "require_availability", False)
@@ -359,6 +387,16 @@ class AssignmentsMixin:
if mode == "unassigned":
return []
# An approved swap replaces the whole active set for that day, ahead of
# every mode's own logic. `require_availability` is deliberately not
# re-applied: a parent explicitly approved this child for today, which
# outranks an availability entity. "everyone" chores have no single
# assignee to move, and async_request_swap already refuses them.
if mode != "everyone":
swapped_to = self._swap_override(chore, today)
if swapped_to:
return [swapped_to]
if mode == "first_come":
# Competitive: every child in the resolved pool sees it until the
# first completion fills the shared quota (see _is_rotation_done_today).
@@ -458,9 +496,7 @@ class AssignmentsMixin:
return result
def _apply_sticky_policy(
self, group, chore_by_id: dict[str, Chore], result: dict[str, str]
) -> None:
def _apply_sticky_policy(self, group, chore_by_id: dict[str, Chore], result: dict[str, str]) -> None:
"""Force followers onto the leader chore's assignee (when in pool)."""
leader_id = group.chore_ids[0]
leader_child = result.get(leader_id)
@@ -478,12 +514,12 @@ class AssignmentsMixin:
else:
_LOGGER.debug(
"STICKY fallback: leader %s assigned to %s not in follower %s pool",
leader_id, leader_child, follower_id,
leader_id,
leader_child,
follower_id,
)
def _apply_spread_policy(
self, group, chore_by_id: dict[str, Chore], result: dict[str, str]
) -> None:
def _apply_spread_policy(self, group, chore_by_id: dict[str, Chore], result: dict[str, str]) -> None:
"""Assign group members to distinct children; wraps when pool < group size."""
used: set[str] = set()
for chore_id in group.chore_ids:
@@ -529,17 +565,18 @@ class AssignmentsMixin:
size = len(pool)
# Cache per-call so the same child isn't queried twice in a scan.
cache: dict[str, bool] = {}
def available(cid: str) -> bool:
if cid not in cache:
cache[cid] = self._is_child_available(cid)
return cache[cid]
for step in range(size):
cid = pool[(start_idx + step) % size]
if available(cid):
return cid
_LOGGER.debug(
"Availability skip: no available child in pool %s for chore, "
"hiding chore (all children unavailable)",
"Availability skip: no available child in pool %s for chore, hiding chore (all children unavailable)",
pool,
)
return ""
@@ -557,7 +594,7 @@ class AssignmentsMixin:
active child still has uncompleted bonus sub-tasks for today, keep
the chore visible (return False) so they remain reachable.
"""
if getattr(chore, 'assignment_mode', 'everyone') == 'everyone':
if getattr(chore, "assignment_mode", "everyone") == "everyone":
return False
# PERF-1: result depends only on the chore; memoize per availability build.
cache = getattr(self, "_avail_cache", None)
@@ -573,7 +610,7 @@ class AssignmentsMixin:
if not pool:
return False
today = dt_util.as_local(dt_util.now()).date()
active_child_id = getattr(chore, 'assignment_current_child_id', '') or ''
active_child_id = getattr(chore, "assignment_current_child_id", "") or ""
completions_today = 0
completed_bonus_ids_today: set[str] = set()
for comp in self._cached_completions():
@@ -581,14 +618,14 @@ class AssignmentsMixin:
continue
comp_dt = comp.completed_at
try:
if hasattr(comp_dt, 'astimezone'):
if hasattr(comp_dt, "astimezone"):
comp_dt = dt_util.as_local(comp_dt)
comp_date = comp_dt.date() if hasattr(comp_dt, 'date') else None
comp_date = comp_dt.date() if hasattr(comp_dt, "date") else None
except (AttributeError, TypeError, ValueError):
continue
if comp_date != today:
continue
bonus_id = getattr(comp, 'bonus_subtask_id', None)
bonus_id = getattr(comp, "bonus_subtask_id", None)
if bonus_id:
# Bonus completions don't count toward the parent's daily
# quota; track them only to decide whether the active child
@@ -601,16 +638,16 @@ class AssignmentsMixin:
if comp.child_id in pool or comp.child_id == "__parent__":
completions_today += 1
# first_come is a single-winner race: clamp any mis-configured quota to 1.
if getattr(chore, 'assignment_mode', 'everyone') == 'first_come':
if getattr(chore, "assignment_mode", "everyone") == "first_come":
daily_limit = 1
else:
daily_limit = getattr(chore, 'daily_limit', 1) or 1
daily_limit = getattr(chore, "daily_limit", 1) or 1
if completions_today < daily_limit:
return False
bonus_subtasks = getattr(chore, 'bonus_subtasks', None) or []
bonus_subtasks = getattr(chore, "bonus_subtasks", None) or []
if bonus_subtasks and active_child_id:
for bst in bonus_subtasks:
bst_id = getattr(bst, 'id', None)
bst_id = getattr(bst, "id", None)
if bst_id and bst_id not in completed_bonus_ids_today:
return False
return True
@@ -621,8 +658,8 @@ class AssignmentsMixin:
Runs at midnight. All chores are processed concurrently so the runtime
is bounded by the slowest single publish, not the sum across chores.
Also clears stale skip state (skip_date != today) so yesterday's skip
doesn't bleed into the new day.
Also clears stale skip and swap state (dated != today) so yesterday's
skip or approved sibling swap doesn't bleed into the new day.
"""
today = dt_util.as_local(dt_util.now()).date()
today_iso = today.isoformat()
@@ -630,11 +667,16 @@ class AssignmentsMixin:
if not chores:
return
# Clear stale skip state in-memory (persisted via update_chore below).
# Clear stale skip/swap state in-memory (persisted via update_chore
# below). Both are read-time-guarded by their date too, so this is
# housekeeping rather than a correctness requirement.
for chore in chores:
if getattr(chore, "skip_date", "") and chore.skip_date != today_iso:
chore.skip_date = ""
chore.skip_count = 0
if getattr(chore, "assignment_swap_date", "") and chore.assignment_swap_date != today_iso:
chore.assignment_swap_date = ""
chore.assignment_swap_child_id = ""
# Group-aware daily assignment map.
daily = self._compute_daily_assignments(today)
@@ -650,11 +692,15 @@ class AssignmentsMixin:
await self._publish_chore_to_calendars(chore, today)
if list(getattr(chore, "publish_calendar_published_dates", []) or []) != before:
dirty = True
# Always persist if skip state was cleared above.
# Always persist if skip/swap state was cleared above.
if getattr(chore, "skip_date", "") == "" and getattr(chore, "skip_count", 0) == 0:
stored = self.storage.get_chore(chore.id)
if stored and (stored.skip_date or stored.skip_count):
dirty = True
if getattr(chore, "assignment_swap_date", "") == "":
stored = self.storage.get_chore(chore.id)
if stored and getattr(stored, "assignment_swap_date", ""):
dirty = True
if dirty:
self.storage.update_chore(chore)
return dirty