46 files
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
"""TaskMate - Family Chore Manager for Home Assistant."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
@@ -121,7 +122,15 @@ from .websocket import async_register_websocket_commands
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BUTTON, Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.NUMBER, Platform.SELECT, Platform.TODO]
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CALENDAR,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.TODO,
|
||||
]
|
||||
|
||||
# Track if services are registered
|
||||
SERVICES_REGISTERED = "services_registered"
|
||||
@@ -152,7 +161,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass.async_create_task(coordinator.notifications.handle_mobile_action(event))
|
||||
|
||||
coordinator._unsub_mobile_action = hass.bus.async_listen(
|
||||
"mobile_app_notification_action", _on_mobile_action,
|
||||
"mobile_app_notification_action",
|
||||
_on_mobile_action,
|
||||
)
|
||||
|
||||
# Register frontend static paths
|
||||
@@ -171,6 +181,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# stack never blocks setup.
|
||||
try:
|
||||
from .intents import async_setup_intents
|
||||
|
||||
async_setup_intents(hass)
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.debug("TaskMate intents not registered: %s", err)
|
||||
@@ -187,9 +198,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
await hass.async_add_executor_job(_load_base_descriptions)
|
||||
|
||||
_async_update_service_descriptions(hass)
|
||||
coordinator.async_add_listener(
|
||||
lambda: _async_update_service_descriptions(hass)
|
||||
)
|
||||
coordinator.async_add_listener(lambda: _async_update_service_descriptions(hass))
|
||||
|
||||
return True
|
||||
|
||||
@@ -207,10 +216,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
# If no more entries, unregister services. Count only coordinator
|
||||
# instances — hass.data[DOMAIN] also holds bookkeeping flags.
|
||||
remaining_entries = [
|
||||
value for value in hass.data[DOMAIN].values()
|
||||
if isinstance(value, TaskMateCoordinator)
|
||||
]
|
||||
remaining_entries = [value for value in hass.data[DOMAIN].values() if isinstance(value, TaskMateCoordinator)]
|
||||
if not remaining_entries:
|
||||
_async_unregister_services(hass)
|
||||
hass.data[DOMAIN][SERVICES_REGISTERED] = False
|
||||
@@ -329,8 +335,16 @@ async def _async_require_parent(hass: HomeAssistant, call: ServiceCall) -> None:
|
||||
|
||||
|
||||
_AUDIT_TARGET_KEYS = (
|
||||
"chore_id", "reward_id", "penalty_id", "bonus_id", "badge_id",
|
||||
"task_group_id", "miss_id", "claim_id", "transaction_id", "type_id",
|
||||
"chore_id",
|
||||
"reward_id",
|
||||
"penalty_id",
|
||||
"bonus_id",
|
||||
"badge_id",
|
||||
"task_group_id",
|
||||
"miss_id",
|
||||
"claim_id",
|
||||
"transaction_id",
|
||||
"type_id",
|
||||
)
|
||||
|
||||
|
||||
@@ -363,9 +377,7 @@ async def _async_record_service_audit(hass: HomeAssistant, call: ServiceCall) ->
|
||||
target = f"{key}={call.data[key]}"
|
||||
break
|
||||
try:
|
||||
await coordinator.async_record_audit(
|
||||
user_id, user_name, f"service.{call.service}", target
|
||||
)
|
||||
await coordinator.async_record_audit(user_id, user_name, f"service.{call.service}", target)
|
||||
except Exception: # noqa: BLE001 - audit must never break the action
|
||||
_LOGGER.debug("Failed to record service audit for %s", call.service, exc_info=True)
|
||||
|
||||
@@ -382,18 +394,18 @@ def _safe(handler):
|
||||
``ServiceValidationError`` is not a ``ValueError``, so a handler that already
|
||||
raises it (e.g. complete_chore) passes through untouched.
|
||||
"""
|
||||
|
||||
@wraps(handler)
|
||||
async def wrapped(call: ServiceCall) -> None:
|
||||
try:
|
||||
await handler(call)
|
||||
except ValueError as err:
|
||||
raise ServiceValidationError(str(err)) from err
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
async def _async_require_linked_child(
|
||||
hass: HomeAssistant, call: ServiceCall, coordinator, child_id: str
|
||||
) -> None:
|
||||
async def _async_require_linked_child(hass: HomeAssistant, call: ServiceCall, coordinator, child_id: str) -> None:
|
||||
"""Restrict a child's self-service call to that child's linked HA user.
|
||||
|
||||
Opt-in: only enforced when the child has a ``linked_user_id`` set. Children
|
||||
@@ -426,11 +438,13 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
|
||||
def _admin(handler):
|
||||
"""Wrap a service handler so only admins (or context-less calls) run it."""
|
||||
|
||||
@wraps(handler)
|
||||
async def wrapped(call: ServiceCall) -> None:
|
||||
await _async_require_admin(hass, call)
|
||||
await handler(call)
|
||||
await _async_record_service_audit(hass, call)
|
||||
|
||||
# Compose with _safe so admin handlers also convert coordinator
|
||||
# ValueErrors into clean validation errors. The admin gate raises
|
||||
# Unauthorized (not ValueError), so it is unaffected and still 401s.
|
||||
@@ -441,11 +455,13 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
|
||||
Used for day-to-day parent actions. Structural config keeps _admin.
|
||||
"""
|
||||
|
||||
@wraps(handler)
|
||||
async def wrapped(call: ServiceCall) -> None:
|
||||
await _async_require_parent(hass, call)
|
||||
await handler(call)
|
||||
await _async_record_service_audit(hass, call)
|
||||
|
||||
return _safe(wrapped)
|
||||
|
||||
async def handle_complete_chore(call: ServiceCall) -> None:
|
||||
@@ -468,7 +484,9 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
await _async_require_linked_child(hass, call, coordinator, child_id)
|
||||
try:
|
||||
await coordinator.async_complete_chore(
|
||||
chore_id, child_id, as_parent=as_parent,
|
||||
chore_id,
|
||||
child_id,
|
||||
as_parent=as_parent,
|
||||
photo_url=call.data.get("photo_url", ""),
|
||||
)
|
||||
except ValueError as err:
|
||||
@@ -497,9 +515,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
_LOGGER.error("No TaskMate coordinator available")
|
||||
return
|
||||
await _async_require_linked_child(hass, call, coordinator, call.data[ATTR_CHILD_ID])
|
||||
await coordinator.async_start_timed_task(
|
||||
call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID]
|
||||
)
|
||||
await coordinator.async_start_timed_task(call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID])
|
||||
|
||||
async def handle_pause_timed_task(call: ServiceCall) -> None:
|
||||
"""Handle the pause_timed_task service call."""
|
||||
@@ -508,9 +524,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
_LOGGER.error("No TaskMate coordinator available")
|
||||
return
|
||||
await _async_require_linked_child(hass, call, coordinator, call.data[ATTR_CHILD_ID])
|
||||
await coordinator.async_pause_timed_task(
|
||||
call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID]
|
||||
)
|
||||
await coordinator.async_pause_timed_task(call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID])
|
||||
|
||||
async def handle_stop_timed_task(call: ServiceCall) -> None:
|
||||
"""Handle the stop_timed_task service call."""
|
||||
@@ -519,9 +533,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
_LOGGER.error("No TaskMate coordinator available")
|
||||
return
|
||||
await _async_require_linked_child(hass, call, coordinator, call.data[ATTR_CHILD_ID])
|
||||
await coordinator.async_stop_timed_task(
|
||||
call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID]
|
||||
)
|
||||
await coordinator.async_stop_timed_task(call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID])
|
||||
|
||||
async def handle_approve_chore(call: ServiceCall) -> None:
|
||||
"""Handle the approve_chore service call."""
|
||||
@@ -604,7 +616,9 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
_LOGGER.error("No TaskMate coordinator available")
|
||||
return
|
||||
await coordinator.async_gift_points(
|
||||
call.data["from_child_id"], call.data["to_child_id"], call.data["points"],
|
||||
call.data["from_child_id"],
|
||||
call.data["to_child_id"],
|
||||
call.data["points"],
|
||||
)
|
||||
|
||||
async def handle_record_allowance_payout(call: ServiceCall) -> None:
|
||||
@@ -614,7 +628,8 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
_LOGGER.error("No TaskMate coordinator available")
|
||||
return
|
||||
await coordinator.async_record_allowance_payout(
|
||||
call.data["child_id"], call.data["points"],
|
||||
call.data["child_id"],
|
||||
call.data["points"],
|
||||
)
|
||||
|
||||
async def handle_request_swap(call: ServiceCall) -> None:
|
||||
@@ -867,9 +882,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
_LOGGER.error("No TaskMate coordinator available")
|
||||
return
|
||||
try:
|
||||
await coordinator.async_set_chore_manual_start(
|
||||
call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID]
|
||||
)
|
||||
await coordinator.async_set_chore_manual_start(call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID])
|
||||
except ValueError as err:
|
||||
_LOGGER.warning("set_chore_manual_start rejected: %s", err)
|
||||
raise
|
||||
@@ -1154,16 +1167,22 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
|
||||
_miss_schema = vol.Schema({vol.Required("miss_id"): cv.string})
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_APPLY_MANDATORY_PENALTY,
|
||||
_parent(handle_apply_mandatory_penalty), schema=_miss_schema,
|
||||
DOMAIN,
|
||||
SERVICE_APPLY_MANDATORY_PENALTY,
|
||||
_parent(handle_apply_mandatory_penalty),
|
||||
schema=_miss_schema,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_POSTPONE_MANDATORY_CHORE,
|
||||
_parent(handle_postpone_mandatory_chore), schema=_miss_schema,
|
||||
DOMAIN,
|
||||
SERVICE_POSTPONE_MANDATORY_CHORE,
|
||||
_parent(handle_postpone_mandatory_chore),
|
||||
schema=_miss_schema,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_DISMISS_MANDATORY_CHORE,
|
||||
_parent(handle_dismiss_mandatory_chore), schema=_miss_schema,
|
||||
DOMAIN,
|
||||
SERVICE_DISMISS_MANDATORY_CHORE,
|
||||
_parent(handle_dismiss_mandatory_chore),
|
||||
schema=_miss_schema,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
@@ -1240,12 +1259,14 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
DOMAIN,
|
||||
SERVICE_READ_ALOUD,
|
||||
_safe(handle_read_aloud),
|
||||
schema=vol.Schema({
|
||||
vol.Required(ATTR_CHILD_ID): cv.string,
|
||||
vol.Optional("media_player", default=""): cv.string,
|
||||
vol.Optional("tts_entity", default=""): cv.string,
|
||||
vol.Optional("message", default=""): cv.string,
|
||||
}),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_CHILD_ID): cv.string,
|
||||
vol.Optional("media_player", default=""): cv.string,
|
||||
vol.Optional("tts_entity", default=""): cv.string,
|
||||
vol.Optional("message", default=""): cv.string,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
@@ -1283,7 +1304,7 @@ 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}),
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
@@ -1342,11 +1363,28 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
_safe(handle_preview_sound),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_SOUND): vol.In([
|
||||
"none", "coin", "levelup", "fanfare", "chime", "powerup", "undo",
|
||||
"fart1", "fart2", "fart3", "fart4", "fart5", "fart6", "fart7",
|
||||
"fart8", "fart9", "fart10", "fart_random",
|
||||
]),
|
||||
vol.Required(ATTR_SOUND): vol.In(
|
||||
[
|
||||
"none",
|
||||
"coin",
|
||||
"levelup",
|
||||
"fanfare",
|
||||
"chime",
|
||||
"powerup",
|
||||
"undo",
|
||||
"fart1",
|
||||
"fart2",
|
||||
"fart3",
|
||||
"fart4",
|
||||
"fart5",
|
||||
"fart6",
|
||||
"fart7",
|
||||
"fart8",
|
||||
"fart9",
|
||||
"fart10",
|
||||
"fart_random",
|
||||
]
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -1363,32 +1401,35 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_ADD_PENALTY,
|
||||
_admin(handle_add_penalty),
|
||||
schema=vol.Schema({
|
||||
vol.Required(ATTR_PENALTY_NAME): cv.string,
|
||||
vol.Required(ATTR_PENALTY_POINTS): cv.positive_int,
|
||||
vol.Optional(ATTR_PENALTY_DESCRIPTION, default=""): cv.string,
|
||||
vol.Optional(ATTR_PENALTY_ICON, default="mdi:alert-circle-outline"): cv.string,
|
||||
vol.Optional(ATTR_PENALTY_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]),
|
||||
}),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_PENALTY_NAME): cv.string,
|
||||
vol.Required(ATTR_PENALTY_POINTS): cv.positive_int,
|
||||
vol.Optional(ATTR_PENALTY_DESCRIPTION, default=""): cv.string,
|
||||
vol.Optional(ATTR_PENALTY_ICON, default="mdi:alert-circle-outline"): cv.string,
|
||||
vol.Optional(ATTR_PENALTY_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_UPDATE_PENALTY,
|
||||
_admin(handle_update_penalty),
|
||||
schema=vol.Schema({
|
||||
vol.Required(ATTR_PENALTY_ID): cv.string,
|
||||
vol.Optional(ATTR_PENALTY_NAME): cv.string,
|
||||
vol.Optional(ATTR_PENALTY_POINTS): cv.positive_int,
|
||||
vol.Optional(ATTR_PENALTY_DESCRIPTION): cv.string,
|
||||
vol.Optional(ATTR_PENALTY_ICON): cv.string,
|
||||
vol.Optional(ATTR_PENALTY_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]),
|
||||
}),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_PENALTY_ID): cv.string,
|
||||
vol.Optional(ATTR_PENALTY_NAME): cv.string,
|
||||
vol.Optional(ATTR_PENALTY_POINTS): cv.positive_int,
|
||||
vol.Optional(ATTR_PENALTY_DESCRIPTION): cv.string,
|
||||
vol.Optional(ATTR_PENALTY_ICON): cv.string,
|
||||
vol.Optional(ATTR_PENALTY_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
@@ -1402,37 +1443,43 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
DOMAIN,
|
||||
SERVICE_APPLY_PENALTY,
|
||||
_parent(handle_apply_penalty),
|
||||
schema=vol.Schema({
|
||||
vol.Required(ATTR_PENALTY_ID): cv.string,
|
||||
vol.Required(ATTR_CHILD_ID): cv.string,
|
||||
}),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_PENALTY_ID): cv.string,
|
||||
vol.Required(ATTR_CHILD_ID): cv.string,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_ADD_BONUS,
|
||||
_admin(handle_add_bonus),
|
||||
schema=vol.Schema({
|
||||
vol.Required(ATTR_BONUS_NAME): cv.string,
|
||||
vol.Required(ATTR_BONUS_POINTS): cv.positive_int,
|
||||
vol.Optional(ATTR_BONUS_DESCRIPTION, default=""): cv.string,
|
||||
vol.Optional(ATTR_BONUS_ICON, default="mdi:star-circle-outline"): cv.string,
|
||||
vol.Optional(ATTR_BONUS_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]),
|
||||
}),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_BONUS_NAME): cv.string,
|
||||
vol.Required(ATTR_BONUS_POINTS): cv.positive_int,
|
||||
vol.Optional(ATTR_BONUS_DESCRIPTION, default=""): cv.string,
|
||||
vol.Optional(ATTR_BONUS_ICON, default="mdi:star-circle-outline"): cv.string,
|
||||
vol.Optional(ATTR_BONUS_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_UPDATE_BONUS,
|
||||
_admin(handle_update_bonus),
|
||||
schema=vol.Schema({
|
||||
vol.Required(ATTR_BONUS_ID): cv.string,
|
||||
vol.Optional(ATTR_BONUS_NAME): cv.string,
|
||||
vol.Optional(ATTR_BONUS_POINTS): cv.positive_int,
|
||||
vol.Optional(ATTR_BONUS_DESCRIPTION): cv.string,
|
||||
vol.Optional(ATTR_BONUS_ICON): cv.string,
|
||||
vol.Optional(ATTR_BONUS_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]),
|
||||
}),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_BONUS_ID): cv.string,
|
||||
vol.Optional(ATTR_BONUS_NAME): cv.string,
|
||||
vol.Optional(ATTR_BONUS_POINTS): cv.positive_int,
|
||||
vol.Optional(ATTR_BONUS_DESCRIPTION): cv.string,
|
||||
vol.Optional(ATTR_BONUS_ICON): cv.string,
|
||||
vol.Optional(ATTR_BONUS_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
@@ -1446,30 +1493,32 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
DOMAIN,
|
||||
SERVICE_APPLY_BONUS,
|
||||
_parent(handle_apply_bonus),
|
||||
schema=vol.Schema({
|
||||
vol.Required(ATTR_BONUS_ID): cv.string,
|
||||
vol.Required(ATTR_CHILD_ID): cv.string,
|
||||
}),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_BONUS_ID): cv.string,
|
||||
vol.Required(ATTR_CHILD_ID): cv.string,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_ADD_CHORE,
|
||||
_admin(handle_add_chore),
|
||||
schema=vol.Schema({
|
||||
vol.Required(ATTR_CHORE_NAME): cv.string,
|
||||
vol.Optional(ATTR_CHORE_DESCRIPTION, default=""): cv.string,
|
||||
vol.Optional(ATTR_CHORE_POINTS, default=10): cv.positive_int,
|
||||
vol.Optional(ATTR_CHORE_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(ATTR_CHORE_TIME_CATEGORY, default="anytime"): vol.In(TIME_CATEGORIES),
|
||||
vol.Optional("difficulty", default=DEFAULT_DIFFICULTY): vol.In(DIFFICULTY_TIERS),
|
||||
vol.Optional(ATTR_CHORE_ONE_SHOT, default=False): cv.boolean,
|
||||
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,
|
||||
}),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_CHORE_NAME): cv.string,
|
||||
vol.Optional(ATTR_CHORE_DESCRIPTION, default=""): cv.string,
|
||||
vol.Optional(ATTR_CHORE_POINTS, default=10): cv.positive_int,
|
||||
vol.Optional(ATTR_CHORE_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(ATTR_CHORE_TIME_CATEGORY, default="anytime"): vol.In(TIME_CATEGORIES),
|
||||
vol.Optional("difficulty", default=DEFAULT_DIFFICULTY): vol.In(DIFFICULTY_TIERS),
|
||||
vol.Optional(ATTR_CHORE_ONE_SHOT, default=False): cv.boolean,
|
||||
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,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
@@ -1483,33 +1532,39 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
DOMAIN,
|
||||
SERVICE_SET_CHORE_MANUAL_START,
|
||||
_admin(handle_set_chore_manual_start),
|
||||
schema=vol.Schema({
|
||||
vol.Required(ATTR_CHORE_ID): cv.string,
|
||||
vol.Required(ATTR_CHILD_ID): cv.string,
|
||||
}),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_CHORE_ID): cv.string,
|
||||
vol.Required(ATTR_CHILD_ID): cv.string,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_ADD_TASK_GROUP,
|
||||
_admin(handle_add_task_group),
|
||||
schema=vol.Schema({
|
||||
vol.Required(CONF_TASK_GROUP_NAME): cv.string,
|
||||
vol.Required(CONF_TASK_GROUP_POLICY): vol.In(TASK_GROUP_POLICIES),
|
||||
vol.Optional(CONF_TASK_GROUP_CHORE_IDS, default=[]): vol.All(cv.ensure_list, [cv.string]),
|
||||
}),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_TASK_GROUP_NAME): cv.string,
|
||||
vol.Required(CONF_TASK_GROUP_POLICY): vol.In(TASK_GROUP_POLICIES),
|
||||
vol.Optional(CONF_TASK_GROUP_CHORE_IDS, default=[]): vol.All(cv.ensure_list, [cv.string]),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_UPDATE_TASK_GROUP,
|
||||
_admin(handle_update_task_group),
|
||||
schema=vol.Schema({
|
||||
vol.Required(CONF_TASK_GROUP_ID): cv.string,
|
||||
vol.Optional(CONF_TASK_GROUP_NAME): cv.string,
|
||||
vol.Optional(CONF_TASK_GROUP_POLICY): vol.In(TASK_GROUP_POLICIES),
|
||||
vol.Optional(CONF_TASK_GROUP_CHORE_IDS): vol.All(cv.ensure_list, [cv.string]),
|
||||
}),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_TASK_GROUP_ID): cv.string,
|
||||
vol.Optional(CONF_TASK_GROUP_NAME): cv.string,
|
||||
vol.Optional(CONF_TASK_GROUP_POLICY): vol.In(TASK_GROUP_POLICIES),
|
||||
vol.Optional(CONF_TASK_GROUP_CHORE_IDS): vol.All(cv.ensure_list, [cv.string]),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
@@ -1523,36 +1578,40 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
DOMAIN,
|
||||
"add_badge",
|
||||
_admin(handle_add_badge),
|
||||
schema=vol.Schema({
|
||||
vol.Required(ATTR_BADGE_NAME): cv.string,
|
||||
vol.Optional(ATTR_BADGE_DESCRIPTION, default=""): cv.string,
|
||||
vol.Optional(ATTR_BADGE_ICON, default="mdi:trophy"): cv.string,
|
||||
vol.Optional(ATTR_BADGE_TIER, default="bronze"): vol.In(["bronze", "silver", "gold", "platinum"]),
|
||||
vol.Optional(ATTR_BADGE_POINT_BONUS, default=0): vol.Coerce(int),
|
||||
vol.Optional(ATTR_BADGE_CRITERIA, default=[]): list,
|
||||
vol.Optional(ATTR_BADGE_COMBINATOR, default="AND"): cv.string,
|
||||
vol.Optional(ATTR_BADGE_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(ATTR_BADGE_NOTIFY_ON_EARN, default=True): cv.boolean,
|
||||
}),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_BADGE_NAME): cv.string,
|
||||
vol.Optional(ATTR_BADGE_DESCRIPTION, default=""): cv.string,
|
||||
vol.Optional(ATTR_BADGE_ICON, default="mdi:trophy"): cv.string,
|
||||
vol.Optional(ATTR_BADGE_TIER, default="bronze"): vol.In(["bronze", "silver", "gold", "platinum"]),
|
||||
vol.Optional(ATTR_BADGE_POINT_BONUS, default=0): vol.Coerce(int),
|
||||
vol.Optional(ATTR_BADGE_CRITERIA, default=[]): list,
|
||||
vol.Optional(ATTR_BADGE_COMBINATOR, default="AND"): cv.string,
|
||||
vol.Optional(ATTR_BADGE_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(ATTR_BADGE_NOTIFY_ON_EARN, default=True): cv.boolean,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"update_badge",
|
||||
_admin(handle_update_badge),
|
||||
schema=vol.Schema({
|
||||
vol.Required(ATTR_BADGE_ID): cv.string,
|
||||
vol.Optional(ATTR_BADGE_NAME): cv.string,
|
||||
vol.Optional(ATTR_BADGE_DESCRIPTION): cv.string,
|
||||
vol.Optional(ATTR_BADGE_ICON): cv.string,
|
||||
vol.Optional(ATTR_BADGE_TIER): vol.In(["bronze", "silver", "gold", "platinum"]),
|
||||
vol.Optional(ATTR_BADGE_POINT_BONUS): vol.Coerce(int),
|
||||
vol.Optional(ATTR_BADGE_CRITERIA): list,
|
||||
vol.Optional(ATTR_BADGE_COMBINATOR): cv.string,
|
||||
vol.Optional(ATTR_BADGE_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(ATTR_BADGE_ENABLED): cv.boolean,
|
||||
vol.Optional(ATTR_BADGE_NOTIFY_ON_EARN): cv.boolean,
|
||||
}),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_BADGE_ID): cv.string,
|
||||
vol.Optional(ATTR_BADGE_NAME): cv.string,
|
||||
vol.Optional(ATTR_BADGE_DESCRIPTION): cv.string,
|
||||
vol.Optional(ATTR_BADGE_ICON): cv.string,
|
||||
vol.Optional(ATTR_BADGE_TIER): vol.In(["bronze", "silver", "gold", "platinum"]),
|
||||
vol.Optional(ATTR_BADGE_POINT_BONUS): vol.Coerce(int),
|
||||
vol.Optional(ATTR_BADGE_CRITERIA): list,
|
||||
vol.Optional(ATTR_BADGE_COMBINATOR): cv.string,
|
||||
vol.Optional(ATTR_BADGE_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(ATTR_BADGE_ENABLED): cv.boolean,
|
||||
vol.Optional(ATTR_BADGE_NOTIFY_ON_EARN): cv.boolean,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
@@ -1566,10 +1625,12 @@ async def _async_register_services(hass: HomeAssistant) -> None:
|
||||
DOMAIN,
|
||||
"award_badge_manually",
|
||||
_parent(handle_award_badge_manually),
|
||||
schema=vol.Schema({
|
||||
vol.Required(ATTR_BADGE_ID): cv.string,
|
||||
vol.Required(ATTR_CHILD_ID): cv.string,
|
||||
}),
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_BADGE_ID): cv.string,
|
||||
vol.Required(ATTR_CHILD_ID): cv.string,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Binary sensor platform for TaskMate integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Button platform for TaskMate integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -38,15 +39,11 @@ async def async_setup_entry(
|
||||
if getattr(chore, "assignment_mode", "everyone") == "unassigned":
|
||||
continue
|
||||
if not chore.assigned_to or child.id in chore.assigned_to:
|
||||
entities.append(
|
||||
CompleteChoreButton(coordinator, entry, child, chore)
|
||||
)
|
||||
entities.append(CompleteChoreButton(coordinator, entry, child, chore))
|
||||
|
||||
# Reward claim buttons
|
||||
for reward in rewards:
|
||||
entities.append(
|
||||
ClaimRewardButton(coordinator, entry, child, reward)
|
||||
)
|
||||
entities.append(ClaimRewardButton(coordinator, entry, child, reward))
|
||||
|
||||
# Track which entity combos already exist
|
||||
tracked_combos: set[str] = set()
|
||||
@@ -77,16 +74,12 @@ async def async_setup_entry(
|
||||
if not chore.assigned_to or child.id in chore.assigned_to:
|
||||
key = f"{child.id}_{chore.id}_complete"
|
||||
if key not in tracked_combos:
|
||||
new_entities.append(
|
||||
CompleteChoreButton(coordinator, entry, child, chore)
|
||||
)
|
||||
new_entities.append(CompleteChoreButton(coordinator, entry, child, chore))
|
||||
tracked_combos.add(key)
|
||||
for reward in current_rewards:
|
||||
key = f"{child.id}_{reward.id}_claim"
|
||||
if key not in tracked_combos:
|
||||
new_entities.append(
|
||||
ClaimRewardButton(coordinator, entry, child, reward)
|
||||
)
|
||||
new_entities.append(ClaimRewardButton(coordinator, entry, child, reward))
|
||||
tracked_combos.add(key)
|
||||
|
||||
if new_entities:
|
||||
@@ -142,7 +135,7 @@ class CompleteChoreButton(TaskMateBaseButton):
|
||||
# Chores gained an optional icon in #683, defaulting to "". Fall back on
|
||||
# falsiness, not on the attribute being absent — otherwise every chore
|
||||
# without a picture gets a blank button icon.
|
||||
return (getattr(chore, 'icon', "") or "mdi:check-circle") if chore else "mdi:check-circle"
|
||||
return (getattr(chore, "icon", "") or "mdi:check-circle") if chore else "mdi:check-circle"
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict:
|
||||
|
||||
@@ -15,6 +15,7 @@ to keep in sync:
|
||||
Read-only for now: completing a chore from the calendar is intentionally not
|
||||
supported.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -67,9 +68,7 @@ async def async_setup_entry(
|
||||
coordinator.async_add_listener(_async_add_new)
|
||||
|
||||
|
||||
def _chore_applies_to_child(
|
||||
coordinator: TaskMateCoordinator, chore: Chore, child_id: str, day: date
|
||||
) -> bool:
|
||||
def _chore_applies_to_child(coordinator: TaskMateCoordinator, chore: Chore, child_id: str, day: date) -> bool:
|
||||
"""True if ``chore`` is scheduled for ``child_id`` on ``day``.
|
||||
|
||||
Combines the recurrence schedule with the assignment engine so the calendar
|
||||
@@ -165,9 +164,7 @@ class TaskMateCalendar(CoordinatorEntity, CalendarEntity):
|
||||
return []
|
||||
return self._build_events(child, start_date.date(), end_date.date())
|
||||
|
||||
def _build_events(
|
||||
self, child: Child, start_day: date, end_day: date
|
||||
) -> list[CalendarEvent]:
|
||||
def _build_events(self, child: Child, start_day: date, end_day: date) -> list[CalendarEvent]:
|
||||
coord = self.coordinator
|
||||
events: list[CalendarEvent] = []
|
||||
|
||||
@@ -197,25 +194,27 @@ class TaskMateCalendar(CoordinatorEntity, CalendarEntity):
|
||||
for chore in chores:
|
||||
if not _chore_applies_to_child(coord, chore, child.id, day):
|
||||
continue
|
||||
window = coord._time_category_window(
|
||||
getattr(chore, "time_category", "anytime"), day
|
||||
)
|
||||
window = coord._time_category_window(getattr(chore, "time_category", "anytime"), day)
|
||||
desc = _chore_description(chore)
|
||||
if window is None:
|
||||
events.append(CalendarEvent(
|
||||
start=day,
|
||||
end=day + timedelta(days=1),
|
||||
summary=chore.name,
|
||||
description=desc,
|
||||
))
|
||||
events.append(
|
||||
CalendarEvent(
|
||||
start=day,
|
||||
end=day + timedelta(days=1),
|
||||
summary=chore.name,
|
||||
description=desc,
|
||||
)
|
||||
)
|
||||
else:
|
||||
start_dt, end_dt = window
|
||||
events.append(CalendarEvent(
|
||||
start=start_dt.replace(tzinfo=tz),
|
||||
end=end_dt.replace(tzinfo=tz),
|
||||
summary=chore.name,
|
||||
description=desc,
|
||||
))
|
||||
events.append(
|
||||
CalendarEvent(
|
||||
start=start_dt.replace(tzinfo=tz),
|
||||
end=end_dt.replace(tzinfo=tz),
|
||||
summary=chore.name,
|
||||
description=desc,
|
||||
)
|
||||
)
|
||||
day += timedelta(days=1)
|
||||
|
||||
return events
|
||||
|
||||
@@ -7,6 +7,7 @@ is handled entirely by the dedicated TaskMate admin panel at
|
||||
all of its functionality is available in the panel, and all data is stored in
|
||||
the integration's own ``Store`` rather than in ``config_entry.options``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
@@ -24,9 +25,7 @@ class TaskMateConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Constants for TaskMate integration."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "taskmate"
|
||||
@@ -131,10 +132,10 @@ TIME_CATEGORY_ICONS: Final = {
|
||||
# always available) and never appears in this list. An empty label means
|
||||
# "use the translated built-in name for this id".
|
||||
DEFAULT_TIME_PERIODS: Final = [
|
||||
{"id": "morning", "label": "", "start": "06:00", "end": "12:00", "icon": "mdi:weather-sunny"},
|
||||
{"id": "morning", "label": "", "start": "06:00", "end": "12:00", "icon": "mdi:weather-sunny"},
|
||||
{"id": "afternoon", "label": "", "start": "12:00", "end": "17:00", "icon": "mdi:white-balance-sunny"},
|
||||
{"id": "evening", "label": "", "start": "17:00", "end": "21:00", "icon": "mdi:weather-sunset"},
|
||||
{"id": "night", "label": "", "start": "21:00", "end": "23:59", "icon": "mdi:weather-night"},
|
||||
{"id": "evening", "label": "", "start": "17:00", "end": "21:00", "icon": "mdi:weather-sunset"},
|
||||
{"id": "night", "label": "", "start": "21:00", "end": "23:59", "icon": "mdi:weather-night"},
|
||||
]
|
||||
|
||||
MAX_TIME_PERIODS: Final = 24
|
||||
@@ -340,24 +341,24 @@ STATE_CLAIMED: Final = "claimed"
|
||||
# Most sounds are synthesized via Web Audio API
|
||||
# Fart sounds are CC0 audio files from BigSoundBank.com and GfxSounds.com
|
||||
COMPLETION_SOUND_OPTIONS: Final = [
|
||||
"none", # No sound
|
||||
"coin", # Coin collect sound
|
||||
"levelup", # Level up / success sound
|
||||
"fanfare", # Celebratory fanfare
|
||||
"chime", # Simple chime
|
||||
"powerup", # Power up sound
|
||||
"undo", # Sad/descending "womp womp" for undo actions
|
||||
"fart1", # Flatulence 1 (short)
|
||||
"fart2", # Flatulence 2 (short)
|
||||
"fart3", # Flatulence 3 (short)
|
||||
"fart4", # Pony flatulence 2 (~3 sec)
|
||||
"fart5", # Flatulence 4 - discreet (short)
|
||||
"fart6", # Prout'cochons 1 - pig game sound (short)
|
||||
"fart7", # Prout'cochons 2 - pig game sound (short)
|
||||
"fart8", # Prout'cochons 3 - pig game sound (short)
|
||||
"fart9", # Pony flatulence 1 (short)
|
||||
"fart10", # Baby fart (short)
|
||||
"fart_random", # Random fart - picks a random fart sound each time!
|
||||
"none", # No sound
|
||||
"coin", # Coin collect sound
|
||||
"levelup", # Level up / success sound
|
||||
"fanfare", # Celebratory fanfare
|
||||
"chime", # Simple chime
|
||||
"powerup", # Power up sound
|
||||
"undo", # Sad/descending "womp womp" for undo actions
|
||||
"fart1", # Flatulence 1 (short)
|
||||
"fart2", # Flatulence 2 (short)
|
||||
"fart3", # Flatulence 3 (short)
|
||||
"fart4", # Pony flatulence 2 (~3 sec)
|
||||
"fart5", # Flatulence 4 - discreet (short)
|
||||
"fart6", # Prout'cochons 1 - pig game sound (short)
|
||||
"fart7", # Prout'cochons 2 - pig game sound (short)
|
||||
"fart8", # Prout'cochons 3 - pig game sound (short)
|
||||
"fart9", # Pony flatulence 1 (short)
|
||||
"fart10", # Baby fart (short)
|
||||
"fart_random", # Random fart - picks a random fart sound each time!
|
||||
]
|
||||
|
||||
# Default completion sound
|
||||
@@ -377,22 +378,22 @@ DEFAULT_DIFFICULTY: Final = "medium"
|
||||
DEFAULT_DIFFICULTY_MULTIPLIERS: Final = {"easy": 0.5, "medium": 1.0, "hard": 2.0}
|
||||
|
||||
# --- Notification type IDs (v3.9.0) ---
|
||||
NOTIF_TYPE_BEDTIME_REMINDER: Final = "bedtime_reminder"
|
||||
NOTIF_TYPE_STREAK_AT_RISK: Final = "streak_at_risk"
|
||||
NOTIF_TYPE_ALL_CHORES_DONE: Final = "all_chores_done"
|
||||
NOTIF_TYPE_BADGE_EARNED: Final = "badge_earned"
|
||||
NOTIF_TYPE_PENDING_CHORE_APPROVAL: Final = "pending_chore_approval"
|
||||
NOTIF_TYPE_PENDING_REWARD_CLAIM: Final = "pending_reward_claim"
|
||||
NOTIF_TYPE_STREAK_MILESTONE: Final = "streak_milestone"
|
||||
NOTIF_TYPE_LEVEL_UP: Final = "level_up"
|
||||
NOTIF_TYPE_WEEKLY_DIGEST: Final = "weekly_digest"
|
||||
NOTIF_TYPE_CELEBRATION: Final = "celebration"
|
||||
NOTIF_TYPE_MANDATORY_REMINDER: Final = "mandatory_reminder"
|
||||
NOTIF_TYPE_MANDATORY_PARENT_ALERT: Final = "mandatory_parent_alert"
|
||||
NOTIF_TYPE_MONTHLY_REPORT: Final = "monthly_report"
|
||||
NOTIF_TYPE_SEASON_CHAMPION: Final = "season_champion"
|
||||
NOTIF_TYPE_FAMILY_GOAL_REACHED: Final = "family_goal_reached"
|
||||
NOTIF_TYPE_BEDTIME_REMINDER: Final = "bedtime_reminder"
|
||||
NOTIF_TYPE_STREAK_AT_RISK: Final = "streak_at_risk"
|
||||
NOTIF_TYPE_ALL_CHORES_DONE: Final = "all_chores_done"
|
||||
NOTIF_TYPE_BADGE_EARNED: Final = "badge_earned"
|
||||
NOTIF_TYPE_PENDING_CHORE_APPROVAL: Final = "pending_chore_approval"
|
||||
NOTIF_TYPE_PENDING_REWARD_CLAIM: Final = "pending_reward_claim"
|
||||
NOTIF_TYPE_STREAK_MILESTONE: Final = "streak_milestone"
|
||||
NOTIF_TYPE_LEVEL_UP: Final = "level_up"
|
||||
NOTIF_TYPE_WEEKLY_DIGEST: Final = "weekly_digest"
|
||||
NOTIF_TYPE_CELEBRATION: Final = "celebration"
|
||||
NOTIF_TYPE_MANDATORY_REMINDER: Final = "mandatory_reminder"
|
||||
NOTIF_TYPE_MANDATORY_PARENT_ALERT: Final = "mandatory_parent_alert"
|
||||
NOTIF_TYPE_MONTHLY_REPORT: Final = "monthly_report"
|
||||
NOTIF_TYPE_SEASON_CHAMPION: Final = "season_champion"
|
||||
NOTIF_TYPE_FAMILY_GOAL_REACHED: Final = "family_goal_reached"
|
||||
|
||||
# Default notification tap target. Must match PANEL_URL_PATH in panel.py —
|
||||
# a bare /taskmate is the static-files prefix and returns 403, not the panel.
|
||||
DEFAULT_NOTIFICATION_NAV_URL: Final = "/taskmate-admin"
|
||||
DEFAULT_NOTIFICATION_NAV_URL: Final = "/taskmate-admin"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ a lifetime-points total, or a best-streak length — or ``free`` for always
|
||||
available). Children unlock avatars by hitting those milestones and can switch
|
||||
to any avatar they've unlocked; parents can set any catalogue avatar.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -14,14 +15,14 @@ _LOGGER = logging.getLogger(__name__)
|
||||
# Shipped defaults so the feature is useful out of the box. Parents can replace
|
||||
# the whole list from the panel.
|
||||
DEFAULT_AVATAR_CATALOG: list[dict] = [
|
||||
{"id": "starter", "label": "Starter", "icon": "mdi:account-circle", "unlock_type": "free", "unlock_value": 0},
|
||||
{"id": "rocket", "label": "Rocket", "icon": "mdi:rocket-launch", "unlock_type": "level", "unlock_value": 3},
|
||||
{"id": "robot", "label": "Robot", "icon": "mdi:robot-happy", "unlock_type": "level", "unlock_value": 5},
|
||||
{"id": "ninja", "label": "Ninja", "icon": "mdi:ninja", "unlock_type": "level", "unlock_value": 10},
|
||||
{"id": "crown", "label": "Royalty", "icon": "mdi:crown", "unlock_type": "points", "unlock_value": 500},
|
||||
{"id": "trophy", "label": "Champion", "icon": "mdi:trophy", "unlock_type": "points", "unlock_value": 1000},
|
||||
{"id": "fire", "label": "On Fire", "icon": "mdi:fire", "unlock_type": "streak", "unlock_value": 7},
|
||||
{"id": "diamond", "label": "Diamond", "icon": "mdi:diamond-stone", "unlock_type": "streak", "unlock_value": 30},
|
||||
{"id": "starter", "label": "Starter", "icon": "mdi:account-circle", "unlock_type": "free", "unlock_value": 0},
|
||||
{"id": "rocket", "label": "Rocket", "icon": "mdi:rocket-launch", "unlock_type": "level", "unlock_value": 3},
|
||||
{"id": "robot", "label": "Robot", "icon": "mdi:robot-happy", "unlock_type": "level", "unlock_value": 5},
|
||||
{"id": "ninja", "label": "Ninja", "icon": "mdi:ninja", "unlock_type": "level", "unlock_value": 10},
|
||||
{"id": "crown", "label": "Royalty", "icon": "mdi:crown", "unlock_type": "points", "unlock_value": 500},
|
||||
{"id": "trophy", "label": "Champion", "icon": "mdi:trophy", "unlock_type": "points", "unlock_value": 1000},
|
||||
{"id": "fire", "label": "On Fire", "icon": "mdi:fire", "unlock_type": "streak", "unlock_value": 7},
|
||||
{"id": "diamond", "label": "Diamond", "icon": "mdi:diamond-stone", "unlock_type": "streak", "unlock_value": 30},
|
||||
]
|
||||
|
||||
|
||||
@@ -67,13 +68,15 @@ class AvatarsMixin:
|
||||
req = f"{value}-day streak"
|
||||
else:
|
||||
req = ""
|
||||
out.append({
|
||||
"id": entry.get("id", entry.get("icon")),
|
||||
"label": entry.get("label", ""),
|
||||
"icon": entry.get("icon"),
|
||||
"unlocked": self._avatar_unlocked(entry, child),
|
||||
"requirement": req,
|
||||
})
|
||||
out.append(
|
||||
{
|
||||
"id": entry.get("id", entry.get("icon")),
|
||||
"label": entry.get("label", ""),
|
||||
"icon": entry.get("icon"),
|
||||
"unlocked": self._avatar_unlocked(entry, child),
|
||||
"requirement": req,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
async def async_update_avatar_catalog(self, catalog: list[dict]) -> None:
|
||||
@@ -83,13 +86,15 @@ class AvatarsMixin:
|
||||
icon = (a.get("icon") or "").strip()
|
||||
if not icon:
|
||||
continue
|
||||
cleaned.append({
|
||||
"id": (a.get("id") or icon).strip(),
|
||||
"label": (a.get("label") or "").strip(),
|
||||
"icon": icon,
|
||||
"unlock_type": a.get("unlock_type", "free"),
|
||||
"unlock_value": int(a.get("unlock_value", 0) or 0),
|
||||
})
|
||||
cleaned.append(
|
||||
{
|
||||
"id": (a.get("id") or icon).strip(),
|
||||
"label": (a.get("label") or "").strip(),
|
||||
"icon": icon,
|
||||
"unlock_type": a.get("unlock_type", "free"),
|
||||
"unlock_value": int(a.get("unlock_value", 0) or 0),
|
||||
}
|
||||
)
|
||||
self.storage.set_setting("avatar_catalog", cleaned)
|
||||
await self.storage.async_save()
|
||||
await self.async_refresh()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Badge evaluation engine and built-in catalogue."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -8,8 +9,9 @@ from .models import Badge, BadgeCriterion, Child
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _b(id_suffix: str, name: str, description: str, icon: str, tier: str,
|
||||
point_bonus: int, metric: str, value: int) -> Badge:
|
||||
def _b(
|
||||
id_suffix: str, name: str, description: str, icon: str, tier: str, point_bonus: int, metric: str, value: int
|
||||
) -> Badge:
|
||||
"""Helper to build a built-in badge."""
|
||||
criteria = [BadgeCriterion(metric=metric, operator=">=", value=value)] if metric else []
|
||||
badge = Badge(
|
||||
@@ -29,39 +31,116 @@ def _b(id_suffix: str, name: str, description: str, icon: str, tier: str,
|
||||
|
||||
BUILTIN_CATALOGUE: list[Badge] = [
|
||||
# Bronze
|
||||
_b("first_chore", "First Chore", "Complete your very first chore",
|
||||
"mdi:check-circle", "bronze", 0, "first_chore", 1),
|
||||
_b("first_reward", "First Reward", "Claim your first reward",
|
||||
"mdi:gift", "bronze", 0, "first_reward", 1),
|
||||
_b("100_points", "100 Points", "Earn 100 lifetime points",
|
||||
"mdi:star", "bronze", 0, "total_points", 100),
|
||||
_b("10_chores", "10 Chores Completed", "Complete 10 chores",
|
||||
"mdi:checkbox-marked-circle", "bronze", 0, "total_chores", 10),
|
||||
_b(
|
||||
"first_chore",
|
||||
"First Chore",
|
||||
"Complete your very first chore",
|
||||
"mdi:check-circle",
|
||||
"bronze",
|
||||
0,
|
||||
"first_chore",
|
||||
1,
|
||||
),
|
||||
_b("first_reward", "First Reward", "Claim your first reward", "mdi:gift", "bronze", 0, "first_reward", 1),
|
||||
_b("100_points", "100 Points", "Earn 100 lifetime points", "mdi:star", "bronze", 0, "total_points", 100),
|
||||
_b(
|
||||
"10_chores",
|
||||
"10 Chores Completed",
|
||||
"Complete 10 chores",
|
||||
"mdi:checkbox-marked-circle",
|
||||
"bronze",
|
||||
0,
|
||||
"total_chores",
|
||||
10,
|
||||
),
|
||||
# Silver
|
||||
_b("500_points", "500 Points", "Earn 500 lifetime points",
|
||||
"mdi:star-circle", "silver", 25, "total_points", 500),
|
||||
_b("50_chores", "50 Chores Completed", "Complete 50 chores",
|
||||
"mdi:checkbox-multiple-marked-circle", "silver", 25, "total_chores", 50),
|
||||
_b("3_day_streak", "3-Day Streak", "Complete chores 3 days in a row",
|
||||
"mdi:fire", "silver", 25, "current_streak", 3),
|
||||
_b("first_perfect_week", "First Perfect Week", "Complete a perfect week",
|
||||
"mdi:calendar-star", "silver", 50, "perfect_weeks", 1),
|
||||
_b("500_points", "500 Points", "Earn 500 lifetime points", "mdi:star-circle", "silver", 25, "total_points", 500),
|
||||
_b(
|
||||
"50_chores",
|
||||
"50 Chores Completed",
|
||||
"Complete 50 chores",
|
||||
"mdi:checkbox-multiple-marked-circle",
|
||||
"silver",
|
||||
25,
|
||||
"total_chores",
|
||||
50,
|
||||
),
|
||||
_b(
|
||||
"3_day_streak", "3-Day Streak", "Complete chores 3 days in a row", "mdi:fire", "silver", 25, "current_streak", 3
|
||||
),
|
||||
_b(
|
||||
"first_perfect_week",
|
||||
"First Perfect Week",
|
||||
"Complete a perfect week",
|
||||
"mdi:calendar-star",
|
||||
"silver",
|
||||
50,
|
||||
"perfect_weeks",
|
||||
1,
|
||||
),
|
||||
# Gold
|
||||
_b("1000_points", "1000 Points", "Earn 1000 lifetime points",
|
||||
"mdi:trophy", "gold", 100, "total_points", 1000),
|
||||
_b("100_chores", "100 Chores Completed", "Complete 100 chores",
|
||||
"mdi:trophy-variant", "gold", 100, "total_chores", 100),
|
||||
_b("7_day_streak", "7-Day Streak", "Complete chores 7 days in a row",
|
||||
"mdi:lightning-bolt", "gold", 50, "current_streak", 7),
|
||||
_b("5_perfect_weeks", "5 Perfect Weeks", "Achieve 5 perfect weeks",
|
||||
"mdi:calendar-multiple-check", "gold", 100, "perfect_weeks", 5),
|
||||
_b("1000_points", "1000 Points", "Earn 1000 lifetime points", "mdi:trophy", "gold", 100, "total_points", 1000),
|
||||
_b(
|
||||
"100_chores",
|
||||
"100 Chores Completed",
|
||||
"Complete 100 chores",
|
||||
"mdi:trophy-variant",
|
||||
"gold",
|
||||
100,
|
||||
"total_chores",
|
||||
100,
|
||||
),
|
||||
_b(
|
||||
"7_day_streak",
|
||||
"7-Day Streak",
|
||||
"Complete chores 7 days in a row",
|
||||
"mdi:lightning-bolt",
|
||||
"gold",
|
||||
50,
|
||||
"current_streak",
|
||||
7,
|
||||
),
|
||||
_b(
|
||||
"5_perfect_weeks",
|
||||
"5 Perfect Weeks",
|
||||
"Achieve 5 perfect weeks",
|
||||
"mdi:calendar-multiple-check",
|
||||
"gold",
|
||||
100,
|
||||
"perfect_weeks",
|
||||
5,
|
||||
),
|
||||
# Platinum
|
||||
_b("5000_points", "5000 Points", "Earn 5000 lifetime points",
|
||||
"mdi:diamond-stone", "platinum", 250, "total_points", 5000),
|
||||
_b("30_day_streak", "30-Day Streak", "Complete chores 30 days in a row",
|
||||
"mdi:crown", "platinum", 250, "current_streak", 30),
|
||||
_b("10_perfect_weeks", "10 Perfect Weeks", "Achieve 10 perfect weeks",
|
||||
"mdi:rainbow", "platinum", 250, "perfect_weeks", 10),
|
||||
_b(
|
||||
"5000_points",
|
||||
"5000 Points",
|
||||
"Earn 5000 lifetime points",
|
||||
"mdi:diamond-stone",
|
||||
"platinum",
|
||||
250,
|
||||
"total_points",
|
||||
5000,
|
||||
),
|
||||
_b(
|
||||
"30_day_streak",
|
||||
"30-Day Streak",
|
||||
"Complete chores 30 days in a row",
|
||||
"mdi:crown",
|
||||
"platinum",
|
||||
250,
|
||||
"current_streak",
|
||||
30,
|
||||
),
|
||||
_b(
|
||||
"10_perfect_weeks",
|
||||
"10 Perfect Weeks",
|
||||
"Achieve 10 perfect weeks",
|
||||
"mdi:rainbow",
|
||||
"platinum",
|
||||
250,
|
||||
"perfect_weeks",
|
||||
10,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -80,10 +159,7 @@ def resolve_metric(metric: str, child: Child, storage) -> int:
|
||||
if metric == "first_chore":
|
||||
return 1 if (child.total_chores_completed or 0) >= 1 else 0
|
||||
if metric in ("total_rewards", "first_reward"):
|
||||
approved_count = sum(
|
||||
1 for c in storage.get_reward_claims()
|
||||
if c.child_id == child.id and c.approved
|
||||
)
|
||||
approved_count = sum(1 for c in storage.get_reward_claims() if c.child_id == child.id and c.approved)
|
||||
if metric == "first_reward":
|
||||
return 1 if approved_count >= 1 else 0
|
||||
return approved_count
|
||||
@@ -240,7 +316,9 @@ class BadgeCoordinator:
|
||||
self.storage.add_awarded_badge(award)
|
||||
if bonus > 0:
|
||||
await self.points_coord.async_add_points(
|
||||
child_id, bonus, reason=f"Badge: {badge.name}",
|
||||
child_id,
|
||||
bonus,
|
||||
reason=f"Badge: {badge.name}",
|
||||
)
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_badge_earned",
|
||||
@@ -260,9 +338,7 @@ class BadgeCoordinator:
|
||||
|
||||
async def revoke(self, awarded_id: str) -> bool:
|
||||
"""Revoke an awarded badge; reverse bonus_credited if > 0."""
|
||||
matching = [
|
||||
a for a in self.storage.get_awarded_badges() if a.id == awarded_id
|
||||
]
|
||||
matching = [a for a in self.storage.get_awarded_badges() if a.id == awarded_id]
|
||||
if not matching:
|
||||
return False
|
||||
award = matching[0]
|
||||
@@ -288,7 +364,9 @@ class BadgeCoordinator:
|
||||
total = 0
|
||||
for child in self.storage.get_children():
|
||||
new_awards = await self.evaluate_for_child(
|
||||
child.id, "manual", silent=True,
|
||||
child.id,
|
||||
"manual",
|
||||
silent=True,
|
||||
)
|
||||
total += len(new_awards)
|
||||
return total
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Calendar operations mixin for TaskMateCoordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@@ -55,13 +56,15 @@ class CalendarMixin:
|
||||
pid = str(entry.get("id") or "").strip()
|
||||
if not pid or pid == "anytime" or start is None or end is None:
|
||||
continue
|
||||
periods.append({
|
||||
"id": pid,
|
||||
"label": str(entry.get("label") or "").strip(),
|
||||
"start": start.strftime("%H:%M"),
|
||||
"end": end.strftime("%H:%M"),
|
||||
"icon": str(entry.get("icon") or "") or TIME_CATEGORY_ICONS.get(pid, "mdi:clock-outline"),
|
||||
})
|
||||
periods.append(
|
||||
{
|
||||
"id": pid,
|
||||
"label": str(entry.get("label") or "").strip(),
|
||||
"start": start.strftime("%H:%M"),
|
||||
"end": end.strftime("%H:%M"),
|
||||
"icon": str(entry.get("icon") or "") or TIME_CATEGORY_ICONS.get(pid, "mdi:clock-outline"),
|
||||
}
|
||||
)
|
||||
if periods:
|
||||
return sorted(periods, key=lambda p: p["start"])
|
||||
|
||||
@@ -74,13 +77,15 @@ class CalendarMixin:
|
||||
end_str = self.storage.get_setting(f"time_{pid}_end", default["end"])
|
||||
start = self._parse_hhmm(start_str) or self._parse_hhmm(default["start"])
|
||||
end = self._parse_hhmm(end_str) or self._parse_hhmm(default["end"])
|
||||
periods.append({
|
||||
"id": pid,
|
||||
"label": "",
|
||||
"start": start.strftime("%H:%M"),
|
||||
"end": end.strftime("%H:%M"),
|
||||
"icon": default["icon"],
|
||||
})
|
||||
periods.append(
|
||||
{
|
||||
"id": pid,
|
||||
"label": "",
|
||||
"start": start.strftime("%H:%M"),
|
||||
"end": end.strftime("%H:%M"),
|
||||
"icon": default["icon"],
|
||||
}
|
||||
)
|
||||
return sorted(periods, key=lambda p: p["start"])
|
||||
|
||||
def _get_time_boundaries(self) -> dict[str, tuple[time, time] | None]:
|
||||
@@ -109,9 +114,9 @@ class CalendarMixin:
|
||||
def _calendar_projection_days(self) -> int:
|
||||
"""Return the configured projection horizon, clamped to the allowed range."""
|
||||
try:
|
||||
raw = int(float(self.storage.get_setting(
|
||||
"calendar_projection_days", str(DEFAULT_CALENDAR_PROJECTION_DAYS)
|
||||
)))
|
||||
raw = int(
|
||||
float(self.storage.get_setting("calendar_projection_days", str(DEFAULT_CALENDAR_PROJECTION_DAYS)))
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
raw = DEFAULT_CALENDAR_PROJECTION_DAYS
|
||||
return max(MIN_CALENDAR_PROJECTION_DAYS, min(MAX_CALENDAR_PROJECTION_DAYS, raw))
|
||||
@@ -204,9 +209,7 @@ class CalendarMixin:
|
||||
def _build_event_payload(self, chore: Chore, day: date, summary: str) -> dict:
|
||||
"""Build the calendar.create_event payload for one (chore, day)."""
|
||||
description = self._chore_event_marker(chore)
|
||||
window = self._time_category_window(
|
||||
getattr(chore, "time_category", "anytime"), day
|
||||
)
|
||||
window = self._time_category_window(getattr(chore, "time_category", "anytime"), day)
|
||||
if window is None:
|
||||
return {
|
||||
"summary": summary,
|
||||
|
||||
@@ -5,6 +5,7 @@ points this week — and awards a one-off bonus when a child hits it. Progress
|
||||
and the award reset automatically when the period rolls over (a new day or a
|
||||
new Monday-anchored week).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -89,19 +90,21 @@ class ChallengesMixin:
|
||||
_, period_key = self._period_start_key(ch.scope)
|
||||
prog = self.storage.get_challenge_child_progress(ch.id, child_id)
|
||||
awarded = bool(prog.get("awarded")) and prog.get("period") == period_key
|
||||
out.append({
|
||||
"challenge_id": ch.id,
|
||||
"name": ch.name,
|
||||
"icon": ch.icon,
|
||||
"scope": ch.scope,
|
||||
"metric": ch.metric,
|
||||
"target": ch.target,
|
||||
"progress": min(value, ch.target),
|
||||
"value": value,
|
||||
"bonus_points": ch.bonus_points,
|
||||
"complete": value >= ch.target,
|
||||
"awarded": awarded,
|
||||
})
|
||||
out.append(
|
||||
{
|
||||
"challenge_id": ch.id,
|
||||
"name": ch.name,
|
||||
"icon": ch.icon,
|
||||
"scope": ch.scope,
|
||||
"metric": ch.metric,
|
||||
"target": ch.target,
|
||||
"progress": min(value, ch.target),
|
||||
"value": value,
|
||||
"bonus_points": ch.bonus_points,
|
||||
"complete": value >= ch.target,
|
||||
"awarded": awarded,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
# ── Evaluation ───────────────────────────────────────────────────────
|
||||
@@ -142,24 +145,36 @@ class ChallengesMixin:
|
||||
child.points += bonus
|
||||
child.total_points_earned += bonus
|
||||
child.career_score = child.total_points_earned - child.total_penalties_received
|
||||
self.storage.add_points_transaction(PointsTransaction(
|
||||
child_id=child.id, points=bonus,
|
||||
reason=f"Challenge complete: {challenge.name}", created_at=dt_util.now(),
|
||||
))
|
||||
self.storage.add_points_transaction(
|
||||
PointsTransaction(
|
||||
child_id=child.id,
|
||||
points=bonus,
|
||||
reason=f"Challenge complete: {challenge.name}",
|
||||
created_at=dt_util.now(),
|
||||
)
|
||||
)
|
||||
if hasattr(self, "_maybe_level_up"):
|
||||
await self._maybe_level_up(child)
|
||||
self.storage.update_child(child)
|
||||
|
||||
self.hass.bus.async_fire("taskmate_challenge_completed", {
|
||||
"child_id": child.id, "child_name": child.name,
|
||||
"challenge_id": challenge.id, "challenge_name": challenge.name,
|
||||
"scope": challenge.scope, "bonus": bonus,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_challenge_completed",
|
||||
{
|
||||
"child_id": child.id,
|
||||
"child_name": child.name,
|
||||
"challenge_id": challenge.id,
|
||||
"challenge_name": challenge.name,
|
||||
"scope": challenge.scope,
|
||||
"bonus": bonus,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
},
|
||||
)
|
||||
if hasattr(self, "_celebrate"):
|
||||
await self._celebrate(
|
||||
child, "challenge_completed",
|
||||
child,
|
||||
"challenge_completed",
|
||||
f"{child.name} completed the challenge '{challenge.name}'!",
|
||||
tier=2, extra={"challenge_id": challenge.id, "bonus": bonus},
|
||||
tier=2,
|
||||
extra={"challenge_id": challenge.id, "bonus": bonus},
|
||||
)
|
||||
_LOGGER.info("Challenge '%s' completed by %s (+%d)", challenge.name, child.name, bonus)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Chore operations mixin for TaskMateCoordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -26,10 +27,15 @@ def _add_months(d: date, months: int) -> date:
|
||||
|
||||
|
||||
_DOW_MAP = {
|
||||
'monday': 0, 'tuesday': 1, 'wednesday': 2, 'thursday': 3,
|
||||
'friday': 4, 'saturday': 5, 'sunday': 6,
|
||||
"monday": 0,
|
||||
"tuesday": 1,
|
||||
"wednesday": 2,
|
||||
"thursday": 3,
|
||||
"friday": 4,
|
||||
"saturday": 5,
|
||||
"sunday": 6,
|
||||
}
|
||||
_MONTH_STEPS = {'monthly': 1, 'every_3_months': 3, 'every_6_months': 6}
|
||||
_MONTH_STEPS = {"monthly": 1, "every_3_months": 3, "every_6_months": 6}
|
||||
|
||||
|
||||
class ChoresMixin:
|
||||
@@ -124,7 +130,9 @@ class ChoresMixin:
|
||||
# For random/balanced manual-start, override today's cached child so
|
||||
# the parent sees the chosen child immediately.
|
||||
if manual_start_child_id and resolved_mode in ("random", "balanced"):
|
||||
resolved_pool = self._chore_assignment_pool(chore) if chore.assigned_to else [c.id for c in self.storage.get_children()]
|
||||
resolved_pool = (
|
||||
self._chore_assignment_pool(chore) if chore.assigned_to else [c.id for c in self.storage.get_children()]
|
||||
)
|
||||
if manual_start_child_id in resolved_pool:
|
||||
chore.assignment_current_child_id = manual_start_child_id
|
||||
self.storage.add_chore(chore)
|
||||
@@ -138,6 +146,7 @@ class ChoresMixin:
|
||||
async def async_request_swap(self, chore_id: str, requester_id: str) -> str:
|
||||
"""A child requests to take over today's rotation assignment of a chore."""
|
||||
from .models import generate_id
|
||||
|
||||
chore = self.get_chore(chore_id)
|
||||
if not chore:
|
||||
raise ValueError(f"Chore {chore_id} not found")
|
||||
@@ -164,20 +173,37 @@ class ChoresMixin:
|
||||
|
||||
async def async_approve_swap(self, req_id: str) -> None:
|
||||
"""Approve a swap — reassign today's chore to the requester."""
|
||||
req = next((r for r in self.storage.get_swap_requests()
|
||||
if r.get("id") == req_id and r.get("status") == "pending"), None)
|
||||
req = next(
|
||||
(r for r in self.storage.get_swap_requests() if r.get("id") == req_id and r.get("status") == "pending"),
|
||||
None,
|
||||
)
|
||||
if not req:
|
||||
raise ValueError(f"Swap request {req_id} not found")
|
||||
chore = self.get_chore(req["chore_id"])
|
||||
if chore:
|
||||
# Stamp the dated override *and* the cached current child. The
|
||||
# override is what every eligibility gate reads (#781); the cached
|
||||
# field keeps the sensors/cards consistent without waiting for the
|
||||
# next assignment pass.
|
||||
chore.assignment_swap_child_id = req["requester_id"]
|
||||
chore.assignment_swap_date = dt_util.as_local(dt_util.now()).date().isoformat()
|
||||
chore.assignment_current_child_id = req["requester_id"]
|
||||
self.storage.update_chore(chore)
|
||||
self.storage.update_swap_request(req_id, status="approved")
|
||||
self.hass.bus.async_fire("taskmate_swap_approved", {
|
||||
"chore_id": req["chore_id"], "requester_id": req["requester_id"],
|
||||
"from_child_id": req.get("from_child_id", ""),
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
# Consume the request, exactly as rejection does (#783). Nothing reads a
|
||||
# request once it leaves "pending" — both readers filter on it — so
|
||||
# keeping it would grow the store forever. The approval stays observable
|
||||
# through the event below and the chore's own dated override. `req` is
|
||||
# already captured, so the payload survives the removal.
|
||||
self.storage.remove_swap_request(req_id)
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_swap_approved",
|
||||
{
|
||||
"chore_id": req["chore_id"],
|
||||
"requester_id": req["requester_id"],
|
||||
"from_child_id": req.get("from_child_id", ""),
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
},
|
||||
)
|
||||
await self.storage.async_save()
|
||||
await self.async_refresh()
|
||||
|
||||
@@ -197,7 +223,8 @@ class ChoresMixin:
|
||||
except (ValueError, TypeError):
|
||||
_LOGGER.warning(
|
||||
"Chore '%s' has an unparseable deadline_at %r — ignoring it",
|
||||
getattr(chore, "name", ""), raw,
|
||||
getattr(chore, "name", ""),
|
||||
raw,
|
||||
)
|
||||
return None
|
||||
# A naive value came from hand-edited storage; treat it as local time
|
||||
@@ -237,7 +264,8 @@ class ChoresMixin:
|
||||
changed = True
|
||||
_LOGGER.info(
|
||||
"Reactive chore '%s' expired (deadline %s)",
|
||||
chore.name, getattr(chore, "deadline_at", ""),
|
||||
chore.name,
|
||||
getattr(chore, "deadline_at", ""),
|
||||
)
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_chore_expired",
|
||||
@@ -297,6 +325,8 @@ class ChoresMixin:
|
||||
data["assignment_current_child_id"] = ""
|
||||
data["skip_date"] = ""
|
||||
data["skip_count"] = 0
|
||||
data["assignment_swap_child_id"] = ""
|
||||
data["assignment_swap_date"] = ""
|
||||
data["publish_calendar_published_dates"] = []
|
||||
data["disabled_for"] = []
|
||||
data["enabled"] = True
|
||||
@@ -352,9 +382,7 @@ class ChoresMixin:
|
||||
await self.async_refresh()
|
||||
return count
|
||||
|
||||
async def async_approve_chores_bulk(
|
||||
self, completion_ids: list[str] | None = None
|
||||
) -> int:
|
||||
async def async_approve_chores_bulk(self, completion_ids: list[str] | None = None) -> int:
|
||||
"""Approve several pending chore completions at once. Returns count approved.
|
||||
|
||||
If completion_ids is given, only those (still-pending) completions are
|
||||
@@ -390,7 +418,7 @@ class ChoresMixin:
|
||||
visibility_entity: str = "",
|
||||
visibility_state: str = "on",
|
||||
visibility_operator: str = "equals",
|
||||
) -> list[Chore]:
|
||||
) -> list[Chore]:
|
||||
"""Add multiple chores at once with shared settings."""
|
||||
chores = []
|
||||
for name in chore_names:
|
||||
@@ -412,7 +440,7 @@ class ChoresMixin:
|
||||
visibility_entity=visibility_entity,
|
||||
visibility_state=visibility_state,
|
||||
visibility_operator=visibility_operator,
|
||||
)
|
||||
)
|
||||
self.storage.add_chore(chore)
|
||||
chores.append(chore)
|
||||
|
||||
@@ -453,7 +481,10 @@ class ChoresMixin:
|
||||
extra_prefixes.append(f"{prev_name} — ")
|
||||
extra_prefixes.append(f"{chore.name} — ")
|
||||
await self._cleanup_chore_from_calendars(
|
||||
chore, cleanup_entities, today, summary_prefixes=extra_prefixes,
|
||||
chore,
|
||||
cleanup_entities,
|
||||
today,
|
||||
summary_prefixes=extra_prefixes,
|
||||
)
|
||||
self.storage.update_chore(chore)
|
||||
# Replacing or clearing the picture orphans the old file; delete it —
|
||||
@@ -464,9 +495,7 @@ class ChoresMixin:
|
||||
await self.storage.async_save()
|
||||
await self.async_refresh()
|
||||
|
||||
async def _async_release_image(
|
||||
self, image_url: str, *, excluding_chore_id: str = ""
|
||||
) -> None:
|
||||
async def _async_release_image(self, image_url: str, *, excluding_chore_id: str = "") -> None:
|
||||
"""Delete a chore image file, but only if nothing else still shows it.
|
||||
|
||||
`async_clone_chore` copies `image_url` straight from the source, so two
|
||||
@@ -493,9 +522,7 @@ class ChoresMixin:
|
||||
# Nothing sweeps taskmate_images, so the file has to go with the chore —
|
||||
# unless a clone still shows it (#768).
|
||||
if existing is not None and getattr(existing, "image_url", ""):
|
||||
await self._async_release_image(
|
||||
existing.image_url, excluding_chore_id=chore_id
|
||||
)
|
||||
await self._async_release_image(existing.image_url, excluding_chore_id=chore_id)
|
||||
self.storage.remove_chore(chore_id)
|
||||
self.storage.remove_completions_for_chore(chore_id)
|
||||
self.storage.remove_last_completed_for_chore(chore_id)
|
||||
@@ -503,6 +530,9 @@ class ChoresMixin:
|
||||
self.storage.remove_chore_from_task_groups(chore_id)
|
||||
# Drop queued scheduled changes (#675) — nothing left to apply them to.
|
||||
self.storage.remove_scheduled_changes_for_chore(chore_id)
|
||||
# Drop pending swap requests (#785), else they sit in the parent's
|
||||
# approval queue forever showing "?" for the chore that no longer exists.
|
||||
self.storage.remove_swap_requests_for_chore(chore_id)
|
||||
# Remove chore from children's chore_order lists
|
||||
for child in self.storage.get_children():
|
||||
if chore_id in child.chore_order:
|
||||
@@ -533,9 +563,7 @@ class ChoresMixin:
|
||||
# Reject skipping a sticky group follower — the group would drift.
|
||||
group = self.storage.get_task_group_for_chore(chore_id)
|
||||
if group and group.policy == "sticky" and group.chore_ids and group.chore_ids[0] != chore_id:
|
||||
raise ValueError(
|
||||
"Cannot skip a sticky group follower; skip the leader chore instead"
|
||||
)
|
||||
raise ValueError("Cannot skip a sticky group follower; skip the leader chore instead")
|
||||
|
||||
pool = self._chore_assignment_pool(chore)
|
||||
if len(pool) <= 1:
|
||||
@@ -549,6 +577,12 @@ class ChoresMixin:
|
||||
chore.skip_date = today_iso
|
||||
chore.skip_count = 0
|
||||
|
||||
# A skip is a deliberate move of today's assignee, so it supersedes any
|
||||
# approved swap — otherwise the swap override would keep winning and the
|
||||
# skip would silently do nothing.
|
||||
chore.assignment_swap_child_id = ""
|
||||
chore.assignment_swap_date = ""
|
||||
|
||||
# Cyclical: A → B → … → unassigned → back to A.
|
||||
# skip_count < pool_size → advance to next child
|
||||
# skip_count == pool_size → unassigned (no child today)
|
||||
@@ -612,9 +646,13 @@ class ChoresMixin:
|
||||
chore.assigned_to = [child_id] + [c for c in pool if c != child_id]
|
||||
chore.assignment_rotation_anchor = today.isoformat()
|
||||
|
||||
# Any previous skip is wiped — manual start is an explicit reset.
|
||||
# Any previous skip or approved swap is wiped — manual start is an
|
||||
# explicit reset, and a lingering swap override would outrank the
|
||||
# child the parent just picked.
|
||||
chore.skip_date = ""
|
||||
chore.skip_count = 0
|
||||
chore.assignment_swap_child_id = ""
|
||||
chore.assignment_swap_date = ""
|
||||
|
||||
chore.assignment_current_child_id = child_id
|
||||
|
||||
@@ -623,7 +661,9 @@ class ChoresMixin:
|
||||
await self.async_refresh()
|
||||
return chore
|
||||
|
||||
async def async_complete_chore(self, chore_id: str, child_id: str, as_parent: bool = False, photo_url: str = "") -> ChoreCompletion | None:
|
||||
async def async_complete_chore(
|
||||
self, chore_id: str, child_id: str, as_parent: bool = False, photo_url: str = ""
|
||||
) -> ChoreCompletion | None:
|
||||
"""Mark a chore as completed by a child.
|
||||
|
||||
When ``as_parent`` is True the completion auto-approves (the parent is the
|
||||
@@ -664,8 +704,8 @@ class ChoresMixin:
|
||||
# SINGLE daily quota across the whole pool. Enforce it here at completion
|
||||
# time, not just in the card UI — otherwise a caller can award every pool
|
||||
# member by completing the chore once per child_id (one call each).
|
||||
assignment_mode = getattr(chore, 'assignment_mode', 'everyone')
|
||||
if assignment_mode != 'everyone':
|
||||
assignment_mode = getattr(chore, "assignment_mode", "everyone")
|
||||
if assignment_mode != "everyone":
|
||||
if self._is_rotation_done_today(chore):
|
||||
_LOGGER.debug(
|
||||
"complete_chore no-op: '%s' already completed today (rotation quota filled)",
|
||||
@@ -676,16 +716,17 @@ class ChoresMixin:
|
||||
# assignee. Parents (as_parent) may complete on behalf of any pool
|
||||
# member — e.g. ticking it off for the off-rotation child. first_come
|
||||
# keeps its competitive semantics (every pool member may race).
|
||||
if not as_parent and assignment_mode != 'first_come':
|
||||
if not as_parent and assignment_mode != "first_come":
|
||||
if child_id not in self._compute_active_children(chore):
|
||||
_LOGGER.debug(
|
||||
"complete_chore no-op: '%s' not assigned to %s today",
|
||||
chore.name, child.name,
|
||||
chore.name,
|
||||
child.name,
|
||||
)
|
||||
return None
|
||||
|
||||
# Check recurrence window for Mode B chores
|
||||
if getattr(chore, 'schedule_mode', 'specific_days') == 'recurring':
|
||||
if getattr(chore, "schedule_mode", "specific_days") == "recurring":
|
||||
if not self.is_chore_available_for_child(chore, child_id):
|
||||
_LOGGER.debug(
|
||||
"complete_chore no-op: '%s' not available yet (recurrence window)",
|
||||
@@ -694,7 +735,7 @@ class ChoresMixin:
|
||||
return None
|
||||
|
||||
# Check availability for one-shot chores
|
||||
if getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot':
|
||||
if getattr(chore, "schedule_mode", "specific_days") == "one_shot":
|
||||
if not self.is_chore_available_for_child(chore, child_id):
|
||||
_LOGGER.debug(
|
||||
"complete_chore no-op: '%s' not available (one-shot done or expired)",
|
||||
@@ -718,11 +759,13 @@ class ChoresMixin:
|
||||
if comp_dt.date() == today:
|
||||
todays_completions_count += 1
|
||||
|
||||
daily_limit = getattr(chore, 'daily_limit', 1)
|
||||
daily_limit = getattr(chore, "daily_limit", 1)
|
||||
if todays_completions_count >= daily_limit:
|
||||
_LOGGER.debug(
|
||||
"complete_chore no-op: daily limit reached for '%s' (%d/%d today)",
|
||||
chore.name, todays_completions_count, daily_limit,
|
||||
chore.name,
|
||||
todays_completions_count,
|
||||
daily_limit,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -781,7 +824,7 @@ class ChoresMixin:
|
||||
self.storage.set_last_completed(chore_id, child_id, now.isoformat())
|
||||
|
||||
# One-shot: if auto-approved, disable for this child immediately
|
||||
if getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot' and auto_approve:
|
||||
if getattr(chore, "schedule_mode", "specific_days") == "one_shot" and auto_approve:
|
||||
if child_id not in chore.disabled_for:
|
||||
chore.disabled_for.append(child_id)
|
||||
self._check_one_shot_fully_disabled(chore)
|
||||
@@ -792,8 +835,11 @@ class ChoresMixin:
|
||||
# Fire approval notification only if it stays pending
|
||||
if not auto_approve:
|
||||
await self._async_notify_pending_approval(
|
||||
child.name, chore.name, chore.points,
|
||||
completion_id=completion.id, photo_url=completion.photo_url,
|
||||
child.name,
|
||||
chore.name,
|
||||
chore.points,
|
||||
completion_id=completion.id,
|
||||
photo_url=completion.photo_url,
|
||||
)
|
||||
|
||||
await self.async_refresh()
|
||||
@@ -819,19 +865,17 @@ class ChoresMixin:
|
||||
if not chore:
|
||||
raise ValueError(f"Chore {chore_id} not found")
|
||||
|
||||
if not getattr(chore, 'enabled', True):
|
||||
if not getattr(chore, "enabled", True):
|
||||
raise ValueError(f"Chore '{chore.name}' is disabled")
|
||||
|
||||
schedule_mode = getattr(chore, 'schedule_mode', 'specific_days')
|
||||
if schedule_mode == 'one_shot':
|
||||
raise ValueError(
|
||||
f"Chore '{chore.name}' is a one-shot chore and cannot be parent-completed"
|
||||
)
|
||||
schedule_mode = getattr(chore, "schedule_mode", "specific_days")
|
||||
if schedule_mode == "one_shot":
|
||||
raise ValueError(f"Chore '{chore.name}' is a one-shot chore and cannot be parent-completed")
|
||||
|
||||
now = dt_util.now()
|
||||
|
||||
# Determine child pool — empty assigned_to means all children
|
||||
assigned = getattr(chore, 'assigned_to', []) or []
|
||||
assigned = getattr(chore, "assigned_to", []) or []
|
||||
if assigned:
|
||||
child_ids = list(assigned)
|
||||
else:
|
||||
@@ -860,8 +904,12 @@ class ChoresMixin:
|
||||
# the "Current" column / child-stats card stops pointing at the
|
||||
# original child. The pointer recomputes at the next midnight refresh.
|
||||
if getattr(chore, "assignment_mode", "everyone") != "everyone":
|
||||
if getattr(chore, "assignment_current_child_id", ""):
|
||||
if getattr(chore, "assignment_current_child_id", "") or getattr(chore, "assignment_swap_date", ""):
|
||||
chore.assignment_current_child_id = ""
|
||||
# Drop the swap override too, or it would re-point the column
|
||||
# at the swapped-to child on the next assignment pass.
|
||||
chore.assignment_swap_child_id = ""
|
||||
chore.assignment_swap_date = ""
|
||||
self.storage.update_chore(chore)
|
||||
|
||||
await self.storage.async_save()
|
||||
@@ -912,9 +960,7 @@ class ChoresMixin:
|
||||
for c in all_completions
|
||||
)
|
||||
if already_done:
|
||||
raise ValueError(
|
||||
f"Bonus sub-task '{subtask.name}' already completed today."
|
||||
)
|
||||
raise ValueError(f"Bonus sub-task '{subtask.name}' already completed today.")
|
||||
|
||||
completion = ChoreCompletion(
|
||||
chore_id=chore_id,
|
||||
@@ -960,19 +1006,24 @@ class ChoresMixin:
|
||||
comp_date = dt_util.as_local(completion.completed_at).date()
|
||||
is_bonus = bool(completion.bonus_subtask_id)
|
||||
if is_bonus:
|
||||
subtask = next(
|
||||
(b for b in chore.bonus_subtasks if b.id == completion.bonus_subtask_id), None
|
||||
)
|
||||
subtask = next((b for b in chore.bonus_subtasks if b.id == completion.bonus_subtask_id), None)
|
||||
pts = subtask.points if subtask else 0
|
||||
elif completion.timed_duration_seconds > 0 and chore.task_type == "timed":
|
||||
rate_seconds = chore.timed_rate_minutes * 60
|
||||
pts = (completion.timed_duration_seconds // rate_seconds) * chore.timed_rate_points if rate_seconds > 0 else 0
|
||||
pts = (
|
||||
(completion.timed_duration_seconds // rate_seconds) * chore.timed_rate_points
|
||||
if rate_seconds > 0
|
||||
else 0
|
||||
)
|
||||
else:
|
||||
pts = self._apply_time_adjustment(
|
||||
chore, self.effective_chore_points(chore), completion.completed_at
|
||||
)
|
||||
total_awarded = await self._award_points(
|
||||
child, pts, completion_date=comp_date, skip_streak=is_bonus,
|
||||
child,
|
||||
pts,
|
||||
completion_date=comp_date,
|
||||
skip_streak=is_bonus,
|
||||
chore_id=completion.chore_id,
|
||||
)
|
||||
completion.approved = True
|
||||
@@ -984,9 +1035,7 @@ class ChoresMixin:
|
||||
# reviewed (covers single approve AND "approve all", which
|
||||
# reuses this method per completion).
|
||||
if getattr(self, "notifications", None):
|
||||
await self.notifications.clear_approval(
|
||||
"pending_chore_approval", completion_id
|
||||
)
|
||||
await self.notifications.clear_approval("pending_chore_approval", completion_id)
|
||||
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_chore_approved",
|
||||
@@ -999,7 +1048,7 @@ class ChoresMixin:
|
||||
)
|
||||
|
||||
# One-shot: disable for this child on approval (parent completions only)
|
||||
if not is_bonus and getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot':
|
||||
if not is_bonus and getattr(chore, "schedule_mode", "specific_days") == "one_shot":
|
||||
if completion.child_id not in chore.disabled_for:
|
||||
chore.disabled_for.append(completion.child_id)
|
||||
self._check_one_shot_fully_disabled(chore)
|
||||
@@ -1031,13 +1080,17 @@ class ChoresMixin:
|
||||
{"child_name": child.name, "child_id": child.id},
|
||||
)
|
||||
await self._celebrate(
|
||||
child, "all_chores_done",
|
||||
f"{child.name} finished every chore today!", tier=1,
|
||||
child,
|
||||
"all_chores_done",
|
||||
f"{child.name} finished every chore today!",
|
||||
tier=1,
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Cannot approve completion %s: chore (%s) or child (%s) not found",
|
||||
completion_id, completion.chore_id, completion.child_id,
|
||||
completion_id,
|
||||
completion.chore_id,
|
||||
completion.child_id,
|
||||
)
|
||||
return
|
||||
_LOGGER.warning("Completion %s not found for approval", completion_id)
|
||||
@@ -1082,9 +1135,7 @@ class ChoresMixin:
|
||||
and c.child_id == completion.child_id
|
||||
and not c.bonus_subtask_id
|
||||
]
|
||||
child.last_completion_date = (
|
||||
max(remaining).isoformat() if remaining else None
|
||||
)
|
||||
child.last_completion_date = max(remaining).isoformat() if remaining else None
|
||||
# Reverse any streak milestones this completion unlocked.
|
||||
# Milestone bonuses are logged as separate transactions
|
||||
# (not part of points_awarded), so dropping the streak
|
||||
@@ -1094,24 +1145,15 @@ class ChoresMixin:
|
||||
if lost:
|
||||
try:
|
||||
milestones = self.parse_milestone_setting(
|
||||
self.storage.get_setting(
|
||||
"streak_milestones", self.DEFAULT_STREAK_MILESTONES
|
||||
)
|
||||
self.storage.get_setting("streak_milestones", self.DEFAULT_STREAK_MILESTONES)
|
||||
)
|
||||
except ValueError:
|
||||
milestones = self.parse_milestone_setting(
|
||||
self.DEFAULT_STREAK_MILESTONES
|
||||
)
|
||||
milestones = self.parse_milestone_setting(self.DEFAULT_STREAK_MILESTONES)
|
||||
refund = sum(milestones.get(d, 0) for d in lost)
|
||||
if refund > 0:
|
||||
child.points = max(0, child.points - refund)
|
||||
child.total_points_earned = max(
|
||||
0, child.total_points_earned - refund
|
||||
)
|
||||
child.career_score = (
|
||||
child.total_points_earned
|
||||
- child.total_penalties_received
|
||||
)
|
||||
child.total_points_earned = max(0, child.total_points_earned - refund)
|
||||
child.career_score = child.total_points_earned - child.total_penalties_received
|
||||
self.storage.add_points_transaction(
|
||||
PointsTransaction(
|
||||
child_id=child.id,
|
||||
@@ -1120,9 +1162,7 @@ class ChoresMixin:
|
||||
created_at=dt_util.now(),
|
||||
)
|
||||
)
|
||||
child.streak_milestones_achieved = sorted(
|
||||
d for d in achieved if d <= child.current_streak
|
||||
)
|
||||
child.streak_milestones_achieved = sorted(d for d in achieved if d <= child.current_streak)
|
||||
|
||||
self.storage.update_child(child)
|
||||
|
||||
@@ -1133,7 +1173,8 @@ class ChoresMixin:
|
||||
# chore/child on the same day (caller disposes of the records).
|
||||
comp_date = dt_util.as_local(target_completion.completed_at).date()
|
||||
bonus_completions = [
|
||||
c for c in completions
|
||||
c
|
||||
for c in completions
|
||||
if c.chore_id == target_completion.chore_id
|
||||
and c.child_id == target_completion.child_id
|
||||
and c.bonus_subtask_id
|
||||
@@ -1151,13 +1192,11 @@ class ChoresMixin:
|
||||
self.storage.update_child(child)
|
||||
|
||||
# Undo last_completed store so recurrence window resets correctly
|
||||
self.storage.undo_last_completed(
|
||||
target_completion.chore_id, target_completion.child_id
|
||||
)
|
||||
self.storage.undo_last_completed(target_completion.chore_id, target_completion.child_id)
|
||||
|
||||
# One-shot: re-enable for this child
|
||||
chore = self.get_chore(target_completion.chore_id)
|
||||
if chore and getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot':
|
||||
if chore and getattr(chore, "schedule_mode", "specific_days") == "one_shot":
|
||||
if target_completion.child_id in chore.disabled_for:
|
||||
chore.disabled_for.remove(target_completion.child_id)
|
||||
chore.enabled = True
|
||||
@@ -1168,14 +1207,10 @@ class ChoresMixin:
|
||||
async def async_reject_chore(self, completion_id: str) -> None:
|
||||
"""Reject a chore completion and fully reverse all awards if already granted."""
|
||||
completions = self.storage.get_completions()
|
||||
target_completion = next(
|
||||
(c for c in completions if c.id == completion_id), None
|
||||
)
|
||||
target_completion = next((c for c in completions if c.id == completion_id), None)
|
||||
|
||||
if target_completion:
|
||||
bonus_completions = self._reverse_completion_awards(
|
||||
target_completion, completions
|
||||
)
|
||||
bonus_completions = self._reverse_completion_awards(target_completion, completions)
|
||||
for bc in bonus_completions:
|
||||
self.storage.remove_completion(bc.id)
|
||||
|
||||
@@ -1190,21 +1225,22 @@ class ChoresMixin:
|
||||
if target_completion:
|
||||
child = self.get_child(target_completion.child_id)
|
||||
chore = self.get_chore(target_completion.chore_id)
|
||||
self.hass.bus.async_fire("taskmate_chore_rejected", {
|
||||
"child_id": target_completion.child_id,
|
||||
"child_name": getattr(child, "name", ""),
|
||||
"chore_id": target_completion.chore_id,
|
||||
"chore_name": getattr(chore, "name", ""),
|
||||
"completion_id": completion_id,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_chore_rejected",
|
||||
{
|
||||
"child_id": target_completion.child_id,
|
||||
"child_name": getattr(child, "name", ""),
|
||||
"chore_id": target_completion.chore_id,
|
||||
"chore_name": getattr(chore, "name", ""),
|
||||
"completion_id": completion_id,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
},
|
||||
)
|
||||
# Dismiss the mobile approval push for this reviewed completion. Also
|
||||
# covers undoing an already-approved chore (whose push was cleared at
|
||||
# approval): re-clearing a stale tag is a harmless no-op.
|
||||
if getattr(self, "notifications", None):
|
||||
await self.notifications.clear_approval(
|
||||
"pending_chore_approval", completion_id
|
||||
)
|
||||
await self.notifications.clear_approval("pending_chore_approval", completion_id)
|
||||
|
||||
async def async_undo_chore_approval(self, completion_id: str) -> None:
|
||||
"""Undo an accidental approval: reverse the awards and return the
|
||||
@@ -1240,14 +1276,17 @@ class ChoresMixin:
|
||||
|
||||
child = self.get_child(target.child_id)
|
||||
chore = self.get_chore(target.chore_id)
|
||||
self.hass.bus.async_fire("taskmate_chore_approval_undone", {
|
||||
"child_id": target.child_id,
|
||||
"child_name": getattr(child, "name", ""),
|
||||
"chore_id": target.chore_id,
|
||||
"chore_name": getattr(chore, "name", ""),
|
||||
"completion_id": completion_id,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_chore_approval_undone",
|
||||
{
|
||||
"child_id": target.child_id,
|
||||
"child_name": getattr(child, "name", ""),
|
||||
"chore_id": target.chore_id,
|
||||
"chore_name": getattr(chore, "name", ""),
|
||||
"completion_id": completion_id,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
def _check_one_shot_fully_disabled(self, chore) -> None:
|
||||
"""Check if a one-shot chore should be fully disabled (all children done)."""
|
||||
@@ -1278,16 +1317,16 @@ class ChoresMixin:
|
||||
return False
|
||||
|
||||
# Check if chore is globally disabled (soft-disabled one-shot chores)
|
||||
if not getattr(chore, 'enabled', True):
|
||||
if not getattr(chore, "enabled", True):
|
||||
return False
|
||||
|
||||
# Check per-child disabling (one-shot chores completed by this child)
|
||||
disabled_for = getattr(chore, 'disabled_for', [])
|
||||
disabled_for = getattr(chore, "disabled_for", [])
|
||||
if child_id in disabled_for:
|
||||
return False
|
||||
|
||||
# Dynamic assignment — only the active child(ren) see alternating/random chores
|
||||
if getattr(chore, 'assignment_mode', 'everyone') != 'everyone':
|
||||
if getattr(chore, "assignment_mode", "everyone") != "everyone":
|
||||
active = self._compute_active_children(chore)
|
||||
if child_id not in active:
|
||||
return False
|
||||
@@ -1298,9 +1337,9 @@ class ChoresMixin:
|
||||
return False
|
||||
|
||||
# Check visibility entity first — if not visible, chore is not available
|
||||
visibility_entity = getattr(chore, 'visibility_entity', '')
|
||||
visibility_state = getattr(chore, 'visibility_state', 'on')
|
||||
visibility_operator = getattr(chore, 'visibility_operator', 'equals')
|
||||
visibility_entity = getattr(chore, "visibility_entity", "")
|
||||
visibility_state = getattr(chore, "visibility_state", "on")
|
||||
visibility_operator = getattr(chore, "visibility_operator", "equals")
|
||||
if not self._is_visibility_entity_active(visibility_entity, visibility_state, visibility_operator):
|
||||
return False
|
||||
|
||||
@@ -1318,7 +1357,7 @@ class ChoresMixin:
|
||||
|
||||
# Chore dependencies (FEAT-1): this chore unlocks only once every chore
|
||||
# it depends on has an approved completion today by this same child.
|
||||
depends_on = getattr(chore, 'depends_on', []) or []
|
||||
depends_on = getattr(chore, "depends_on", []) or []
|
||||
if depends_on:
|
||||
dep_today = dt_util.as_local(dt_util.now()).date()
|
||||
completions = self._cached_completions()
|
||||
@@ -1327,18 +1366,18 @@ class ChoresMixin:
|
||||
c.chore_id == dep_id
|
||||
and c.child_id == child_id
|
||||
and c.approved
|
||||
and not getattr(c, 'bonus_subtask_id', '')
|
||||
and not getattr(c, "bonus_subtask_id", "")
|
||||
and dt_util.as_local(c.completed_at).date() == dep_today
|
||||
for c in completions
|
||||
)
|
||||
if not satisfied:
|
||||
return False
|
||||
|
||||
schedule_mode = getattr(chore, 'schedule_mode', 'specific_days')
|
||||
schedule_mode = getattr(chore, "schedule_mode", "specific_days")
|
||||
|
||||
# One-shot chores: only available on the day they were created
|
||||
if schedule_mode == 'one_shot':
|
||||
created_date = getattr(chore, 'created_date', '')
|
||||
if schedule_mode == "one_shot":
|
||||
created_date = getattr(chore, "created_date", "")
|
||||
if created_date:
|
||||
today = dt_util.as_local(dt_util.now()).date()
|
||||
try:
|
||||
@@ -1348,25 +1387,25 @@ class ChoresMixin:
|
||||
pass
|
||||
return True
|
||||
|
||||
if schedule_mode != 'recurring':
|
||||
if schedule_mode != "recurring":
|
||||
return True
|
||||
|
||||
recurrence = getattr(chore, 'recurrence', 'weekly')
|
||||
first_occurrence_mode = getattr(chore, 'first_occurrence_mode', 'available_immediately')
|
||||
recurrence_day = getattr(chore, 'recurrence_day', '')
|
||||
recurrence_start = getattr(chore, 'recurrence_start', '')
|
||||
recurrence = getattr(chore, "recurrence", "weekly")
|
||||
first_occurrence_mode = getattr(chore, "first_occurrence_mode", "available_immediately")
|
||||
recurrence_day = getattr(chore, "recurrence_day", "")
|
||||
recurrence_start = getattr(chore, "recurrence_start", "")
|
||||
|
||||
now = dt_util.now()
|
||||
today = dt_util.as_local(now).date()
|
||||
|
||||
window_days = {
|
||||
'every_2_days': 2,
|
||||
'weekly': 7,
|
||||
'every_2_weeks': 14,
|
||||
"every_2_days": 2,
|
||||
"weekly": 7,
|
||||
"every_2_weeks": 14,
|
||||
}.get(recurrence, 7)
|
||||
|
||||
record = self.storage.get_last_completed(chore.id, child_id)
|
||||
current_iso = record.get('current')
|
||||
current_iso = record.get("current")
|
||||
|
||||
if not current_iso:
|
||||
# Never completed — a future recurrence anchor always defers
|
||||
@@ -1377,7 +1416,7 @@ class ChoresMixin:
|
||||
return False
|
||||
except ValueError:
|
||||
pass
|
||||
if first_occurrence_mode == 'wait_for_first_occurrence' and recurrence_day:
|
||||
if first_occurrence_mode == "wait_for_first_occurrence" and recurrence_day:
|
||||
target_dow = _DOW_MAP.get(recurrence_day.lower())
|
||||
if target_dow is not None and today.weekday() != target_dow:
|
||||
return False
|
||||
@@ -1389,7 +1428,7 @@ class ChoresMixin:
|
||||
return True
|
||||
|
||||
# every_2_days with anchor — check alignment
|
||||
if recurrence == 'every_2_days' and recurrence_start:
|
||||
if recurrence == "every_2_days" and recurrence_start:
|
||||
try:
|
||||
anchor = date.fromisoformat(recurrence_start)
|
||||
days_since_anchor = (today - anchor).days
|
||||
@@ -1402,7 +1441,7 @@ class ChoresMixin:
|
||||
pass
|
||||
|
||||
# weekly/every_2_weeks with specific day — only available on that day
|
||||
if recurrence_day and recurrence in ('weekly', 'every_2_weeks'):
|
||||
if recurrence_day and recurrence in ("weekly", "every_2_weeks"):
|
||||
target_dow = _DOW_MAP.get(recurrence_day.lower())
|
||||
if target_dow is not None and today.weekday() != target_dow:
|
||||
return False
|
||||
@@ -1477,7 +1516,9 @@ class ChoresMixin:
|
||||
changed = True
|
||||
_LOGGER.info(
|
||||
"Chore '%s' expired (expires_on %s, today %s)",
|
||||
chore.name, expires_on, today.isoformat(),
|
||||
chore.name,
|
||||
expires_on,
|
||||
today.isoformat(),
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
@@ -1492,11 +1533,11 @@ class ChoresMixin:
|
||||
changed = False
|
||||
|
||||
for chore in self.storage.get_chores():
|
||||
if getattr(chore, 'schedule_mode', 'specific_days') != 'one_shot':
|
||||
if getattr(chore, "schedule_mode", "specific_days") != "one_shot":
|
||||
continue
|
||||
if not getattr(chore, 'enabled', True):
|
||||
if not getattr(chore, "enabled", True):
|
||||
continue
|
||||
created_date = getattr(chore, 'created_date', '')
|
||||
created_date = getattr(chore, "created_date", "")
|
||||
if not created_date:
|
||||
continue
|
||||
try:
|
||||
@@ -1506,7 +1547,9 @@ class ChoresMixin:
|
||||
changed = True
|
||||
_LOGGER.info(
|
||||
"One-shot chore '%s' expired (created %s, today %s)",
|
||||
chore.name, created_date, today.isoformat(),
|
||||
chore.name,
|
||||
created_date,
|
||||
today.isoformat(),
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
@@ -1563,18 +1606,15 @@ class ChoresMixin:
|
||||
if not chore:
|
||||
raise ValueError(f"Unknown chore: {chore_id}")
|
||||
if getattr(chore, "assignment_mode", "everyone") == "everyone":
|
||||
raise ValueError(
|
||||
f"Chore '{chore.name}' uses 'everyone' mode and cannot join a group"
|
||||
)
|
||||
raise ValueError(f"Chore '{chore.name}' uses 'everyone' mode and cannot join a group")
|
||||
existing_group = self.storage.get_task_group_for_chore(chore_id)
|
||||
if existing_group and existing_group.id != exclude_group_id:
|
||||
raise ValueError(
|
||||
f"Chore '{chore.name}' already belongs to group '{existing_group.name}'"
|
||||
)
|
||||
raise ValueError(f"Chore '{chore.name}' already belongs to group '{existing_group.name}'")
|
||||
|
||||
async def async_add_task_group(self, name: str, policy: str, chore_ids: list[str] | None = None):
|
||||
"""Create a task group."""
|
||||
from .models import TaskGroup
|
||||
|
||||
if policy not in ("sticky", "spread"):
|
||||
raise ValueError(f"Unknown task group policy: {policy}")
|
||||
chore_ids = list(chore_ids or [])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Mandatory-chore detection, scheduling, and resolution (#532)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -64,10 +65,7 @@ class MandatoryMixin:
|
||||
|
||||
async def async_detect_mandatory_misses(self, period_id: str, day: date) -> int:
|
||||
"""Create misses for due+incomplete mandatory chores in `period_id`."""
|
||||
existing = {
|
||||
(m.chore_id, m.child_id, m.due_date)
|
||||
for m in self.storage.get_mandatory_misses()
|
||||
}
|
||||
existing = {(m.chore_id, m.child_id, m.due_date) for m in self.storage.get_mandatory_misses()}
|
||||
created = 0
|
||||
for chore in self.storage.get_chores():
|
||||
if not getattr(chore, "mandatory", False):
|
||||
@@ -94,11 +92,17 @@ class MandatoryMixin:
|
||||
)
|
||||
self.storage.add_mandatory_miss(miss)
|
||||
created += 1
|
||||
self.hass.bus.async_fire("taskmate_mandatory_missed", {
|
||||
"miss_id": miss.id, "chore_id": chore.id, "child_id": child_id,
|
||||
"period_id": period_id, "penalty_points": miss.penalty_points,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_mandatory_missed",
|
||||
{
|
||||
"miss_id": miss.id,
|
||||
"chore_id": chore.id,
|
||||
"child_id": child_id,
|
||||
"period_id": period_id,
|
||||
"penalty_points": miss.penalty_points,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
},
|
||||
)
|
||||
if created:
|
||||
await self.storage.async_save()
|
||||
await self.async_refresh()
|
||||
@@ -132,15 +136,22 @@ class MandatoryMixin:
|
||||
name = getattr(chore, "name", "chore")
|
||||
if miss.penalty_points > 0:
|
||||
await self.async_remove_points(
|
||||
miss.child_id, miss.penalty_points,
|
||||
miss.child_id,
|
||||
miss.penalty_points,
|
||||
reason=f"Penalty: {name} (missed mandatory)",
|
||||
)
|
||||
self.storage.remove_mandatory_miss(miss_id)
|
||||
await self.storage.async_save()
|
||||
self.hass.bus.async_fire("taskmate_mandatory_penalty_applied", {
|
||||
"miss_id": miss_id, "chore_id": miss.chore_id, "child_id": miss.child_id,
|
||||
"points": miss.penalty_points, "timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_mandatory_penalty_applied",
|
||||
{
|
||||
"miss_id": miss_id,
|
||||
"chore_id": miss.chore_id,
|
||||
"child_id": miss.child_id,
|
||||
"points": miss.penalty_points,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
},
|
||||
)
|
||||
await self.async_refresh()
|
||||
|
||||
async def async_postpone_mandatory_chore(self, miss_id: str) -> None:
|
||||
@@ -156,10 +167,16 @@ class MandatoryMixin:
|
||||
# else: no window left today -> let normal scheduling resurface tomorrow
|
||||
self.storage.remove_mandatory_miss(miss_id)
|
||||
await self.storage.async_save()
|
||||
self.hass.bus.async_fire("taskmate_mandatory_postponed", {
|
||||
"miss_id": miss_id, "chore_id": miss.chore_id, "child_id": miss.child_id,
|
||||
"next_period": nxt or "", "timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_mandatory_postponed",
|
||||
{
|
||||
"miss_id": miss_id,
|
||||
"chore_id": miss.chore_id,
|
||||
"child_id": miss.child_id,
|
||||
"next_period": nxt or "",
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
},
|
||||
)
|
||||
await self.async_refresh()
|
||||
|
||||
async def async_dismiss_mandatory_chore(self, miss_id: str) -> None:
|
||||
@@ -168,10 +185,15 @@ class MandatoryMixin:
|
||||
return
|
||||
self.storage.remove_mandatory_miss(miss_id)
|
||||
await self.storage.async_save()
|
||||
self.hass.bus.async_fire("taskmate_mandatory_dismissed", {
|
||||
"miss_id": miss_id, "chore_id": miss.chore_id, "child_id": miss.child_id,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_mandatory_dismissed",
|
||||
{
|
||||
"miss_id": miss_id,
|
||||
"chore_id": miss.chore_id,
|
||||
"child_id": miss.child_id,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
},
|
||||
)
|
||||
await self.async_refresh()
|
||||
|
||||
# ---- escalation (FEAT-6) ----------------------------------------------
|
||||
@@ -225,12 +247,14 @@ class MandatoryMixin:
|
||||
for stage in range(miss.escalation_stage + 1, target + 1):
|
||||
if stage in (1, 2):
|
||||
await self.notifications.fire(
|
||||
NOTIF_TYPE_MANDATORY_REMINDER, ctx,
|
||||
NOTIF_TYPE_MANDATORY_REMINDER,
|
||||
ctx,
|
||||
only_recipients={f"child:{miss.child_id}"},
|
||||
)
|
||||
elif stage == 3:
|
||||
await self.notifications.fire(
|
||||
NOTIF_TYPE_MANDATORY_PARENT_ALERT, ctx,
|
||||
NOTIF_TYPE_MANDATORY_PARENT_ALERT,
|
||||
ctx,
|
||||
)
|
||||
miss.escalation_stage = target
|
||||
self.storage.update_mandatory_miss(miss)
|
||||
@@ -261,13 +285,17 @@ class MandatoryMixin:
|
||||
unsub = async_track_time_change(
|
||||
self.hass,
|
||||
self._make_mandatory_period_cb(period_id),
|
||||
hour=hour, minute=minute, second=10,
|
||||
hour=hour,
|
||||
minute=minute,
|
||||
second=10,
|
||||
)
|
||||
self._unsub_mandatory.append(unsub)
|
||||
# Reminder escalation ladder (FEAT-6) — re-evaluate open misses on a tick.
|
||||
self._unsub_mandatory.append(
|
||||
async_track_time_interval(
|
||||
self.hass, self._escalation_tick, _ESCALATION_INTERVAL,
|
||||
self.hass,
|
||||
self._escalation_tick,
|
||||
_ESCALATION_INTERVAL,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -278,9 +306,8 @@ class MandatoryMixin:
|
||||
def _make_mandatory_period_cb(self, period_id: str):
|
||||
@callback
|
||||
def _cb(now: datetime) -> None:
|
||||
self.hass.async_create_task(
|
||||
self.async_detect_mandatory_misses(period_id, dt_util.now().date())
|
||||
)
|
||||
self.hass.async_create_task(self.async_detect_mandatory_misses(period_id, dt_util.now().date()))
|
||||
|
||||
return _cb
|
||||
|
||||
def disarm_mandatory_schedules(self) -> None:
|
||||
|
||||
@@ -9,6 +9,7 @@ All TaskMate notifications flow through this module. It owns:
|
||||
Other coordinators MUST NOT call notify.* / persistent_notification directly
|
||||
once this module is in place. They call self.notifications.fire(...).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -59,34 +60,32 @@ def _approval_tag(entry_id: str) -> str:
|
||||
@dataclass(frozen=True)
|
||||
class NotificationTypeMeta:
|
||||
id: str
|
||||
audience: str # "child" | "parent" | "both"
|
||||
time_gated: bool # has its own scheduled callback
|
||||
per_recipient_time: bool # if True, route.time controls the schedule per recipient
|
||||
actionable: bool # carries Approve/Reject mobile actions
|
||||
default_enabled: bool # default master_enabled state at install
|
||||
audience: str # "child" | "parent" | "both"
|
||||
time_gated: bool # has its own scheduled callback
|
||||
per_recipient_time: bool # if True, route.time controls the schedule per recipient
|
||||
actionable: bool # carries Approve/Reject mobile actions
|
||||
default_enabled: bool # default master_enabled state at install
|
||||
|
||||
|
||||
NOTIFICATION_TYPES: list[NotificationTypeMeta] = [
|
||||
NotificationTypeMeta(NOTIF_TYPE_BEDTIME_REMINDER, "child", True, True, False, False),
|
||||
NotificationTypeMeta(NOTIF_TYPE_STREAK_AT_RISK, "child", True, False, False, False),
|
||||
NotificationTypeMeta(NOTIF_TYPE_ALL_CHORES_DONE, "both", False, False, False, False),
|
||||
NotificationTypeMeta(NOTIF_TYPE_BADGE_EARNED, "both", False, False, False, True),
|
||||
NotificationTypeMeta(NOTIF_TYPE_PENDING_CHORE_APPROVAL, "parent", False, False, True, True),
|
||||
NotificationTypeMeta(NOTIF_TYPE_PENDING_REWARD_CLAIM, "parent", False, False, True, True),
|
||||
NotificationTypeMeta(NOTIF_TYPE_STREAK_MILESTONE, "both", False, False, False, False),
|
||||
NotificationTypeMeta(NOTIF_TYPE_LEVEL_UP, "both", False, False, False, False),
|
||||
NotificationTypeMeta(NOTIF_TYPE_WEEKLY_DIGEST, "parent", False, False, False, False),
|
||||
NotificationTypeMeta(NOTIF_TYPE_CELEBRATION, "both", False, False, False, False),
|
||||
NotificationTypeMeta(NOTIF_TYPE_MANDATORY_REMINDER, "child", False, False, False, False),
|
||||
NotificationTypeMeta(NOTIF_TYPE_BEDTIME_REMINDER, "child", True, True, False, False),
|
||||
NotificationTypeMeta(NOTIF_TYPE_STREAK_AT_RISK, "child", True, False, False, False),
|
||||
NotificationTypeMeta(NOTIF_TYPE_ALL_CHORES_DONE, "both", False, False, False, False),
|
||||
NotificationTypeMeta(NOTIF_TYPE_BADGE_EARNED, "both", False, False, False, True),
|
||||
NotificationTypeMeta(NOTIF_TYPE_PENDING_CHORE_APPROVAL, "parent", False, False, True, True),
|
||||
NotificationTypeMeta(NOTIF_TYPE_PENDING_REWARD_CLAIM, "parent", False, False, True, True),
|
||||
NotificationTypeMeta(NOTIF_TYPE_STREAK_MILESTONE, "both", False, False, False, False),
|
||||
NotificationTypeMeta(NOTIF_TYPE_LEVEL_UP, "both", False, False, False, False),
|
||||
NotificationTypeMeta(NOTIF_TYPE_WEEKLY_DIGEST, "parent", False, False, False, False),
|
||||
NotificationTypeMeta(NOTIF_TYPE_CELEBRATION, "both", False, False, False, False),
|
||||
NotificationTypeMeta(NOTIF_TYPE_MANDATORY_REMINDER, "child", False, False, False, False),
|
||||
NotificationTypeMeta(NOTIF_TYPE_MANDATORY_PARENT_ALERT, "parent", False, False, False, False),
|
||||
NotificationTypeMeta(NOTIF_TYPE_MONTHLY_REPORT, "parent", False, False, False, False),
|
||||
NotificationTypeMeta(NOTIF_TYPE_SEASON_CHAMPION, "both", False, False, False, False),
|
||||
NotificationTypeMeta(NOTIF_TYPE_FAMILY_GOAL_REACHED, "both", False, False, False, False),
|
||||
NotificationTypeMeta(NOTIF_TYPE_MONTHLY_REPORT, "parent", False, False, False, False),
|
||||
NotificationTypeMeta(NOTIF_TYPE_SEASON_CHAMPION, "both", False, False, False, False),
|
||||
NotificationTypeMeta(NOTIF_TYPE_FAMILY_GOAL_REACHED, "both", False, False, False, False),
|
||||
]
|
||||
|
||||
NOTIFICATION_TYPES_BY_ID: dict[str, NotificationTypeMeta] = {
|
||||
t.id: t for t in NOTIFICATION_TYPES
|
||||
}
|
||||
NOTIFICATION_TYPES_BY_ID: dict[str, NotificationTypeMeta] = {t.id: t for t in NOTIFICATION_TYPES}
|
||||
|
||||
|
||||
def _validate_nav_url(value: str) -> str:
|
||||
@@ -101,11 +100,7 @@ def _validate_nav_url(value: str) -> str:
|
||||
return value
|
||||
if value.lower().startswith(("http://", "https://")):
|
||||
return value
|
||||
if (
|
||||
value.startswith("/")
|
||||
and not value.startswith("//")
|
||||
and not any(ord(c) <= 32 or ord(c) == 127 for c in value)
|
||||
):
|
||||
if value.startswith("/") and not value.startswith("//") and not any(ord(c) <= 32 or ord(c) == 127 for c in value):
|
||||
return value
|
||||
raise ValueError("nav_url must be a /path, an http(s) URL, or noAction")
|
||||
|
||||
@@ -145,6 +140,7 @@ def _is_within_quiet_hours(start: str, end: str, now) -> bool:
|
||||
|
||||
class _SafeDict(dict):
|
||||
"""str.format_map dict that leaves missing keys as `{key}` literal."""
|
||||
|
||||
def __missing__(self, key: str) -> str:
|
||||
return "{" + key + "}"
|
||||
|
||||
@@ -155,11 +151,13 @@ class NotificationCoordinator:
|
||||
def __init__(self, hass: HomeAssistant, storage) -> None:
|
||||
self.hass = hass
|
||||
self.storage = storage
|
||||
self._scheduled_unsubs: list = [] # cancellation handles for time triggers
|
||||
self._scheduled_unsubs: list = [] # cancellation handles for time triggers
|
||||
self.coordinator: Any = None
|
||||
|
||||
async def fire(
|
||||
self, type_id: str, context: dict[str, Any],
|
||||
self,
|
||||
type_id: str,
|
||||
context: dict[str, Any],
|
||||
only_recipients: set[str] | None = None,
|
||||
) -> None:
|
||||
"""Dispatch a notification of the given type with the given context.
|
||||
@@ -229,7 +227,8 @@ class NotificationCoordinator:
|
||||
set one up shouldn't be silently excluded from every approval.
|
||||
"""
|
||||
parent = next(
|
||||
(p for p in self.storage.get_parent_recipients() if p.id == recipient_id), None,
|
||||
(p for p in self.storage.get_parent_recipients() if p.id == recipient_id),
|
||||
None,
|
||||
)
|
||||
entity_id = (getattr(parent, "presence_entity", "") or "").strip() if parent else ""
|
||||
if not entity_id:
|
||||
@@ -241,13 +240,16 @@ class NotificationCoordinator:
|
||||
return str(state.state).lower() in ("home", "on", "true", "present")
|
||||
|
||||
def _route_parents(
|
||||
self, type_id: str, cfg, only_recipients: set[str] | None,
|
||||
self,
|
||||
type_id: str,
|
||||
cfg,
|
||||
only_recipients: set[str] | None,
|
||||
) -> set[str]:
|
||||
"""Which parent recipient ids should receive this notification."""
|
||||
candidates = [
|
||||
rid for rid, route in cfg.routes.items()
|
||||
if rid.startswith("parent:") and route.enabled
|
||||
and (only_recipients is None or rid in only_recipients)
|
||||
rid
|
||||
for rid, route in cfg.routes.items()
|
||||
if rid.startswith("parent:") and route.enabled and (only_recipients is None or rid in only_recipients)
|
||||
]
|
||||
if not candidates:
|
||||
return set()
|
||||
@@ -328,20 +330,15 @@ class NotificationCoordinator:
|
||||
if child is None:
|
||||
return False
|
||||
from homeassistant.util import dt as dt_util
|
||||
return _is_within_quiet_hours(
|
||||
child.quiet_hours_start, child.quiet_hours_end, dt_util.now()
|
||||
)
|
||||
|
||||
return _is_within_quiet_hours(child.quiet_hours_start, child.quiet_hours_end, dt_util.now())
|
||||
|
||||
def _resolve_nav_url(self, cfg) -> str:
|
||||
"""Tap target for this notification: per-type override, else global default."""
|
||||
per_type = (getattr(cfg, "nav_url", "") or "").strip()
|
||||
if per_type:
|
||||
return per_type
|
||||
return str(
|
||||
self.storage.get_setting(
|
||||
"notification_nav_url", DEFAULT_NOTIFICATION_NAV_URL
|
||||
) or ""
|
||||
).strip()
|
||||
return str(self.storage.get_setting("notification_nav_url", DEFAULT_NOTIFICATION_NAV_URL) or "").strip()
|
||||
|
||||
def _resolve_notify_service(self, recipient_id: str) -> str:
|
||||
if recipient_id.startswith("child:"):
|
||||
@@ -358,21 +355,21 @@ class NotificationCoordinator:
|
||||
# Built-in types use a baked-in default; will be replaced by translations
|
||||
# in a later task. For now use a safe English fallback so dispatch works.
|
||||
templates = {
|
||||
NOTIF_TYPE_BEDTIME_REMINDER: "{child_name}, you still have chores to do before bedtime.",
|
||||
NOTIF_TYPE_STREAK_AT_RISK: "{child_name}, complete a chore today to keep your {streak}-day streak!",
|
||||
NOTIF_TYPE_ALL_CHORES_DONE: "{child_name} finished every chore today!",
|
||||
NOTIF_TYPE_BADGE_EARNED: "{child_name} earned the {badge_name} badge!",
|
||||
NOTIF_TYPE_BEDTIME_REMINDER: "{child_name}, you still have chores to do before bedtime.",
|
||||
NOTIF_TYPE_STREAK_AT_RISK: "{child_name}, complete a chore today to keep your {streak}-day streak!",
|
||||
NOTIF_TYPE_ALL_CHORES_DONE: "{child_name} finished every chore today!",
|
||||
NOTIF_TYPE_BADGE_EARNED: "{child_name} earned the {badge_name} badge!",
|
||||
NOTIF_TYPE_PENDING_CHORE_APPROVAL: "{child_name} completed '{chore_name}' (+{points} {points_name}) — awaiting approval.",
|
||||
NOTIF_TYPE_PENDING_REWARD_CLAIM: "{child_name} claimed '{reward_name}' ({cost} {points_name}) — awaiting approval.",
|
||||
NOTIF_TYPE_STREAK_MILESTONE: "{child_name} hit a {days}-day streak — +{points} {points_name}!",
|
||||
NOTIF_TYPE_LEVEL_UP: "{child_name} reached level {level}! 🎉",
|
||||
NOTIF_TYPE_WEEKLY_DIGEST: "TaskMate weekly digest:\n{summary}",
|
||||
NOTIF_TYPE_CELEBRATION: "🎉 {message}",
|
||||
NOTIF_TYPE_MANDATORY_REMINDER: "{child_name}, you still need to do '{chore_name}'.",
|
||||
NOTIF_TYPE_PENDING_REWARD_CLAIM: "{child_name} claimed '{reward_name}' ({cost} {points_name}) — awaiting approval.",
|
||||
NOTIF_TYPE_STREAK_MILESTONE: "{child_name} hit a {days}-day streak — +{points} {points_name}!",
|
||||
NOTIF_TYPE_LEVEL_UP: "{child_name} reached level {level}! 🎉",
|
||||
NOTIF_TYPE_WEEKLY_DIGEST: "TaskMate weekly digest:\n{summary}",
|
||||
NOTIF_TYPE_CELEBRATION: "🎉 {message}",
|
||||
NOTIF_TYPE_MANDATORY_REMINDER: "{child_name}, you still need to do '{chore_name}'.",
|
||||
NOTIF_TYPE_MANDATORY_PARENT_ALERT: "{child_name} still hasn't done the mandatory chore '{chore_name}'.",
|
||||
NOTIF_TYPE_MONTHLY_REPORT: "TaskMate {month} report:\n{summary}",
|
||||
NOTIF_TYPE_SEASON_CHAMPION: "🏆 {child_name} won the {month} leaderboard with {points} {points_name}!",
|
||||
NOTIF_TYPE_FAMILY_GOAL_REACHED: "🎉 Family goal reached: {goal_name}! Time for {goal_reward}.",
|
||||
NOTIF_TYPE_MONTHLY_REPORT: "TaskMate {month} report:\n{summary}",
|
||||
NOTIF_TYPE_SEASON_CHAMPION: "🏆 {child_name} won the {month} leaderboard with {points} {points_name}!",
|
||||
NOTIF_TYPE_FAMILY_GOAL_REACHED: "🎉 Family goal reached: {goal_name}! Time for {goal_reward}.",
|
||||
}
|
||||
tpl = context.get("message_template") or templates.get(meta.id, "")
|
||||
try:
|
||||
@@ -383,13 +380,14 @@ class NotificationCoordinator:
|
||||
return tpl
|
||||
|
||||
async def _send_to(
|
||||
self, notify_service: str, message: str,
|
||||
meta: "NotificationTypeMeta", context: dict[str, Any], nav_url: str = "",
|
||||
self,
|
||||
notify_service: str,
|
||||
message: str,
|
||||
meta: "NotificationTypeMeta",
|
||||
context: dict[str, Any],
|
||||
nav_url: str = "",
|
||||
) -> None:
|
||||
domain, service = (
|
||||
notify_service.split(".", 1) if "." in notify_service
|
||||
else ("notify", notify_service)
|
||||
)
|
||||
domain, service = notify_service.split(".", 1) if "." in notify_service else ("notify", notify_service)
|
||||
if domain != "notify":
|
||||
_LOGGER.warning("notify_service must be notify.*, got %s", notify_service)
|
||||
return
|
||||
@@ -419,7 +417,7 @@ class NotificationCoordinator:
|
||||
push["tag"] = _approval_tag(entry_id)
|
||||
push["actions"] = [
|
||||
{"action": f"TASKMATE_APPROVE_{entry_id}", "title": "Approve"},
|
||||
{"action": f"TASKMATE_REJECT_{entry_id}", "title": "Reject"},
|
||||
{"action": f"TASKMATE_REJECT_{entry_id}", "title": "Reject"},
|
||||
]
|
||||
else:
|
||||
data["message"] = f"{message} {_APPROVE_IN_PANEL_HINT}"
|
||||
@@ -472,10 +470,7 @@ class NotificationCoordinator:
|
||||
notify_service = self._resolve_notify_service(recipient_id)
|
||||
if not notify_service:
|
||||
continue
|
||||
domain, service = (
|
||||
notify_service.split(".", 1) if "." in notify_service
|
||||
else ("notify", notify_service)
|
||||
)
|
||||
domain, service = notify_service.split(".", 1) if "." in notify_service else ("notify", notify_service)
|
||||
if domain != "notify" or not service.startswith("mobile_app"):
|
||||
continue
|
||||
if service in cleared_services:
|
||||
@@ -483,7 +478,8 @@ class NotificationCoordinator:
|
||||
cleared_services.add(service)
|
||||
try:
|
||||
await self.hass.services.async_call(
|
||||
"notify", service,
|
||||
"notify",
|
||||
service,
|
||||
{"message": "clear_notification", "data": {"tag": tag}},
|
||||
blocking=False,
|
||||
)
|
||||
@@ -492,7 +488,8 @@ class NotificationCoordinator:
|
||||
|
||||
async def _fire_persistent_notification(self, type_id: str, message: str) -> None:
|
||||
await self.hass.services.async_call(
|
||||
"persistent_notification", "create",
|
||||
"persistent_notification",
|
||||
"create",
|
||||
{
|
||||
"title": "TaskMate",
|
||||
"message": message,
|
||||
@@ -516,7 +513,7 @@ class NotificationCoordinator:
|
||||
return
|
||||
|
||||
if action.startswith("TASKMATE_APPROVE_"):
|
||||
entry_id = action[len("TASKMATE_APPROVE_"):]
|
||||
entry_id = action[len("TASKMATE_APPROVE_") :]
|
||||
try:
|
||||
await coordinator.async_approve_chore(entry_id)
|
||||
return
|
||||
@@ -527,7 +524,7 @@ class NotificationCoordinator:
|
||||
except (ValueError, KeyError):
|
||||
_LOGGER.info("Mobile action %s — entry not found", action)
|
||||
elif action.startswith("TASKMATE_REJECT_"):
|
||||
entry_id = action[len("TASKMATE_REJECT_"):]
|
||||
entry_id = action[len("TASKMATE_REJECT_") :]
|
||||
try:
|
||||
await coordinator.async_reject_chore(entry_id)
|
||||
return
|
||||
@@ -591,7 +588,11 @@ class NotificationCoordinator:
|
||||
_LOGGER.warning("Invalid time %r — skipping schedule", hhmm)
|
||||
return
|
||||
unsub = async_track_time_change(
|
||||
self.hass, callback, hour=hour, minute=minute, second=0,
|
||||
self.hass,
|
||||
callback,
|
||||
hour=hour,
|
||||
minute=minute,
|
||||
second=0,
|
||||
)
|
||||
self._scheduled_unsubs.append(unsub)
|
||||
|
||||
@@ -606,10 +607,12 @@ class NotificationCoordinator:
|
||||
"bedtime_reminder",
|
||||
{"child_name": child.name, "child_id": child_id},
|
||||
)
|
||||
|
||||
return _cb
|
||||
|
||||
async def _streak_at_risk_callback(self, now) -> None:
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
today = dt_util.now().date().isoformat()
|
||||
for child in self.storage.get_children():
|
||||
if (child.current_streak or 0) < 2:
|
||||
@@ -628,6 +631,7 @@ class NotificationCoordinator:
|
||||
def _make_custom_callback(self, custom_id: str):
|
||||
async def _cb(now):
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
n = next(
|
||||
(c for c in self.storage.get_custom_notifications() if c.id == custom_id),
|
||||
None,
|
||||
@@ -659,7 +663,8 @@ class NotificationCoordinator:
|
||||
message = n.message_template
|
||||
service_name = notify_service.split(".", 1)[1] if "." in notify_service else notify_service
|
||||
await self.hass.services.async_call(
|
||||
"notify", service_name,
|
||||
"notify",
|
||||
service_name,
|
||||
{"title": "TaskMate", "message": message},
|
||||
blocking=False,
|
||||
)
|
||||
@@ -667,6 +672,7 @@ class NotificationCoordinator:
|
||||
"taskmate_custom_notification",
|
||||
{"id": n.id, "name": n.name, "recipients": n.recipient_ids},
|
||||
)
|
||||
|
||||
return _cb
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -702,9 +708,7 @@ class NotificationCoordinator:
|
||||
if self.storage.get_notification_config(meta.id).routes:
|
||||
continue # already configured — leave it alone
|
||||
for p in parents:
|
||||
self.storage.set_notification_route(
|
||||
meta.id, p.id, NotificationRoute(enabled=True)
|
||||
)
|
||||
self.storage.set_notification_route(meta.id, p.id, NotificationRoute(enabled=True))
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
@@ -750,13 +754,14 @@ class NotificationCoordinator:
|
||||
"""Returns True if the child has at least one chore assigned today
|
||||
that has no approved/pending completion yet."""
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
today = dt_util.now().date()
|
||||
chores = self.storage.get_chores()
|
||||
completions = self.storage.get_completions()
|
||||
completed_today = {
|
||||
c.chore_id for c in completions
|
||||
if c.child_id == child_id
|
||||
and dt_util.as_local(c.completed_at).date() == today
|
||||
c.chore_id
|
||||
for c in completions
|
||||
if c.child_id == child_id and dt_util.as_local(c.completed_at).date() == today
|
||||
}
|
||||
for chore in chores:
|
||||
if not chore.assigned_to or child_id not in chore.assigned_to:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Points operations mixin for TaskMateCoordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -72,7 +73,7 @@ class PointsMixin:
|
||||
return req
|
||||
|
||||
for child in children:
|
||||
awarded_weeks = list(getattr(child, 'awarded_perfect_weeks', None) or [])
|
||||
awarded_weeks = list(getattr(child, "awarded_perfect_weeks", None) or [])
|
||||
|
||||
# Skip if already awarded for this week
|
||||
if week_key in awarded_weeks:
|
||||
@@ -100,9 +101,7 @@ class PointsMixin:
|
||||
# counts only when EVERY chore due that day was done — not just one.
|
||||
if all_mode and derived_dates:
|
||||
satisfied = all(
|
||||
self._all_due_chores_done(
|
||||
child.id, date.fromisoformat(d), include_rotation=False
|
||||
)
|
||||
self._all_due_chores_done(child.id, date.fromisoformat(d), include_rotation=False)
|
||||
for d in derived_dates
|
||||
)
|
||||
else:
|
||||
@@ -113,9 +112,7 @@ class PointsMixin:
|
||||
child.points += perfect_week_bonus
|
||||
child.total_points_earned += perfect_week_bonus
|
||||
child.career_score = child.total_points_earned - child.total_penalties_received
|
||||
self.storage.append_career_score_snapshot(
|
||||
child.id, today.isoformat(), child.career_score
|
||||
)
|
||||
self.storage.append_career_score_snapshot(child.id, today.isoformat(), child.career_score)
|
||||
self.storage.update_child(child)
|
||||
|
||||
transaction = PointsTransaction(
|
||||
@@ -140,13 +137,17 @@ class PointsMixin:
|
||||
if getattr(self, "badges", None):
|
||||
await self.badges.evaluate_for_child(child.id, "perfect_week")
|
||||
await self._celebrate(
|
||||
child, "perfect_week",
|
||||
child,
|
||||
"perfect_week",
|
||||
f"{child.name} earned a perfect week — +{perfect_week_bonus}!",
|
||||
tier=3, extra={"bonus": perfect_week_bonus},
|
||||
tier=3,
|
||||
extra={"bonus": perfect_week_bonus},
|
||||
)
|
||||
_LOGGER.info(
|
||||
"Perfect week bonus (%d pts) awarded to %s for week of %s",
|
||||
perfect_week_bonus, child.name, week_key,
|
||||
perfect_week_bonus,
|
||||
child.name,
|
||||
week_key,
|
||||
)
|
||||
|
||||
if changed:
|
||||
@@ -213,17 +214,13 @@ class PointsMixin:
|
||||
if streak_mode == "pause" or getattr(child, "streak_paused", False):
|
||||
child.streak_paused = True
|
||||
_LOGGER.info(
|
||||
"Streak paused for %s (last completion: %s, mode=%s)",
|
||||
child.name, last_date_str, streak_mode
|
||||
"Streak paused for %s (last completion: %s, mode=%s)", child.name, last_date_str, streak_mode
|
||||
)
|
||||
else:
|
||||
# Default: reset to 0
|
||||
child.current_streak = 0
|
||||
child.streak_paused = False
|
||||
_LOGGER.info(
|
||||
"Streak reset for %s (last completion: %s, mode=reset)",
|
||||
child.name, last_date_str
|
||||
)
|
||||
_LOGGER.info("Streak reset for %s (last completion: %s, mode=reset)", child.name, last_date_str)
|
||||
self.storage.update_child(child)
|
||||
changed = True
|
||||
|
||||
@@ -243,9 +240,7 @@ class PointsMixin:
|
||||
child.career_score = child.total_points_earned - child.total_penalties_received
|
||||
await self._maybe_level_up(child)
|
||||
self.storage.update_child(child)
|
||||
self.storage.append_career_score_snapshot(
|
||||
child_id, date.today().isoformat(), child.career_score
|
||||
)
|
||||
self.storage.append_career_score_snapshot(child_id, date.today().isoformat(), child.career_score)
|
||||
# Log the manual transaction
|
||||
transaction = PointsTransaction(
|
||||
child_id=child_id,
|
||||
@@ -279,9 +274,7 @@ class PointsMixin:
|
||||
if reason.startswith("Penalty: "):
|
||||
child.total_penalties_received += actual_deducted
|
||||
child.career_score = child.total_points_earned - child.total_penalties_received
|
||||
self.storage.append_career_score_snapshot(
|
||||
child_id, date.today().isoformat(), child.career_score
|
||||
)
|
||||
self.storage.append_career_score_snapshot(child_id, date.today().isoformat(), child.career_score)
|
||||
self.storage.update_child(child)
|
||||
# Log the manual transaction (negative points)
|
||||
transaction = PointsTransaction(
|
||||
@@ -338,9 +331,7 @@ class PointsMixin:
|
||||
if reason.startswith("Gift to ") or reason.startswith("Gift from "):
|
||||
link_id = getattr(target, "link_id", "") or ""
|
||||
if not link_id:
|
||||
raise ValueError(
|
||||
"This gift predates undo support and can't be reversed automatically."
|
||||
)
|
||||
raise ValueError("This gift predates undo support and can't be reversed automatically.")
|
||||
for leg in [t for t in txns if (getattr(t, "link_id", "") or "") == link_id]:
|
||||
leg_child = self.get_child(leg.child_id)
|
||||
if leg_child:
|
||||
@@ -368,9 +359,7 @@ class PointsMixin:
|
||||
# points, so nothing else to reverse.
|
||||
child.career_score = child.total_points_earned - child.total_penalties_received
|
||||
self.storage.update_child(child)
|
||||
self.storage.append_career_score_snapshot(
|
||||
child.id, dt_util.now().date().isoformat(), child.career_score
|
||||
)
|
||||
self.storage.append_career_score_snapshot(child.id, dt_util.now().date().isoformat(), child.career_score)
|
||||
self.storage.remove_points_transaction(transaction_id)
|
||||
await self.storage.async_save()
|
||||
await self.async_refresh()
|
||||
@@ -394,9 +383,7 @@ class PointsMixin:
|
||||
lvl = xp // step + 1
|
||||
return {"level": lvl, "progress": xp - (lvl - 1) * step, "target": step}
|
||||
|
||||
async def _celebrate(
|
||||
self, child, kind: str, message: str, tier: int = 1, extra: dict | None = None
|
||||
) -> None:
|
||||
async def _celebrate(self, child, kind: str, message: str, tier: int = 1, extra: dict | None = None) -> None:
|
||||
"""Central celebration funnel for notable moments.
|
||||
|
||||
Always fires a single ``taskmate_celebration`` event carrying a ``tier``
|
||||
@@ -452,17 +439,30 @@ class PointsMixin:
|
||||
if new < old:
|
||||
return # earned total dropped (e.g. undo); resync quietly
|
||||
for lvl in range(old + 1, new + 1):
|
||||
self.hass.bus.async_fire("taskmate_level_up", {
|
||||
"child_id": child.id, "child_name": child.name,
|
||||
"level": lvl, "timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_level_up",
|
||||
{
|
||||
"child_id": child.id,
|
||||
"child_name": child.name,
|
||||
"level": lvl,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
},
|
||||
)
|
||||
if getattr(self, "notifications", None):
|
||||
await self.notifications.fire("level_up", {
|
||||
"child_name": child.name, "child_id": child.id, "level": lvl,
|
||||
})
|
||||
await self.notifications.fire(
|
||||
"level_up",
|
||||
{
|
||||
"child_name": child.name,
|
||||
"child_id": child.id,
|
||||
"level": lvl,
|
||||
},
|
||||
)
|
||||
await self._celebrate(
|
||||
child, "level_up", f"{child.name} reached level {lvl}!",
|
||||
tier=3 if lvl % 5 == 0 else 2, extra={"level": lvl},
|
||||
child,
|
||||
"level_up",
|
||||
f"{child.name} reached level {lvl}!",
|
||||
tier=3 if lvl % 5 == 0 else 2,
|
||||
extra={"level": lvl},
|
||||
)
|
||||
|
||||
async def async_gift_points(self, from_child_id: str, to_child_id: str, points: int) -> None:
|
||||
@@ -481,9 +481,7 @@ class PointsMixin:
|
||||
if not sender or not recipient:
|
||||
raise ValueError("Sender or recipient not found")
|
||||
if (sender.points or 0) < points:
|
||||
raise ValueError(
|
||||
f"Not enough points: {sender.name} has {sender.points}, gift {points}"
|
||||
)
|
||||
raise ValueError(f"Not enough points: {sender.name} has {sender.points}, gift {points}")
|
||||
now = dt_util.now()
|
||||
sender.points -= points
|
||||
recipient.points += points
|
||||
@@ -491,19 +489,35 @@ class PointsMixin:
|
||||
self.storage.update_child(recipient)
|
||||
# Shared link_id so undo can reverse both legs together.
|
||||
gift_link = generate_id()
|
||||
self.storage.add_points_transaction(PointsTransaction(
|
||||
child_id=sender.id, points=-points,
|
||||
reason=f"Gift to {recipient.name}", created_at=now, link_id=gift_link,
|
||||
))
|
||||
self.storage.add_points_transaction(PointsTransaction(
|
||||
child_id=recipient.id, points=points,
|
||||
reason=f"Gift from {sender.name}", created_at=now, link_id=gift_link,
|
||||
))
|
||||
self.hass.bus.async_fire("taskmate_points_gifted", {
|
||||
"from_child_id": sender.id, "from_child_name": sender.name,
|
||||
"to_child_id": recipient.id, "to_child_name": recipient.name,
|
||||
"points": points, "timestamp": now.isoformat(),
|
||||
})
|
||||
self.storage.add_points_transaction(
|
||||
PointsTransaction(
|
||||
child_id=sender.id,
|
||||
points=-points,
|
||||
reason=f"Gift to {recipient.name}",
|
||||
created_at=now,
|
||||
link_id=gift_link,
|
||||
)
|
||||
)
|
||||
self.storage.add_points_transaction(
|
||||
PointsTransaction(
|
||||
child_id=recipient.id,
|
||||
points=points,
|
||||
reason=f"Gift from {sender.name}",
|
||||
created_at=now,
|
||||
link_id=gift_link,
|
||||
)
|
||||
)
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_points_gifted",
|
||||
{
|
||||
"from_child_id": sender.id,
|
||||
"from_child_name": sender.name,
|
||||
"to_child_id": recipient.id,
|
||||
"to_child_name": recipient.name,
|
||||
"points": points,
|
||||
"timestamp": now.isoformat(),
|
||||
},
|
||||
)
|
||||
await self.storage.async_save()
|
||||
await self.async_refresh()
|
||||
|
||||
@@ -526,10 +540,7 @@ class PointsMixin:
|
||||
return
|
||||
period = self.storage.get_setting("points_decay_period", "monthly")
|
||||
today = dt_util.now().date()
|
||||
due = (
|
||||
(period == "weekly" and today.weekday() == 0)
|
||||
or (period == "monthly" and today.day == 1)
|
||||
)
|
||||
due = (period == "weekly" and today.weekday() == 0) or (period == "monthly" and today.day == 1)
|
||||
if not due:
|
||||
return
|
||||
if self.storage.get_setting("points_decay_last", "") == today.isoformat():
|
||||
@@ -544,14 +555,23 @@ class PointsMixin:
|
||||
continue
|
||||
child.points = max(0, child.points - loss)
|
||||
self.storage.update_child(child)
|
||||
self.storage.add_points_transaction(PointsTransaction(
|
||||
child_id=child.id, points=-loss,
|
||||
reason=f"Points decay (-{pct:.0f}%)", created_at=now,
|
||||
))
|
||||
self.hass.bus.async_fire("taskmate_points_decay", {
|
||||
"child_id": child.id, "child_name": child.name,
|
||||
"points": loss, "timestamp": now.isoformat(),
|
||||
})
|
||||
self.storage.add_points_transaction(
|
||||
PointsTransaction(
|
||||
child_id=child.id,
|
||||
points=-loss,
|
||||
reason=f"Points decay (-{pct:.0f}%)",
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_points_decay",
|
||||
{
|
||||
"child_id": child.id,
|
||||
"child_name": child.name,
|
||||
"points": loss,
|
||||
"timestamp": now.isoformat(),
|
||||
},
|
||||
)
|
||||
changed = True
|
||||
self.storage.set_setting("points_decay_last", today.isoformat())
|
||||
if changed:
|
||||
@@ -576,10 +596,7 @@ class PointsMixin:
|
||||
return
|
||||
period = self.storage.get_setting("interest_period", "weekly")
|
||||
today = dt_util.now().date()
|
||||
due = (
|
||||
(period == "weekly" and today.weekday() == 0)
|
||||
or (period == "monthly" and today.day == 1)
|
||||
)
|
||||
due = (period == "weekly" and today.weekday() == 0) or (period == "monthly" and today.day == 1)
|
||||
if not due:
|
||||
return
|
||||
if self.storage.get_setting("interest_last", "") == today.isoformat():
|
||||
@@ -592,10 +609,15 @@ class PointsMixin:
|
||||
if interest <= 0:
|
||||
continue
|
||||
await self.async_add_points(child.id, interest, reason=f"Savings interest (+{pct:.0f}%)")
|
||||
self.hass.bus.async_fire("taskmate_interest_paid", {
|
||||
"child_id": child.id, "child_name": child.name,
|
||||
"points": interest, "timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_interest_paid",
|
||||
{
|
||||
"child_id": child.id,
|
||||
"child_name": child.name,
|
||||
"points": interest,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
},
|
||||
)
|
||||
self.storage.set_setting("interest_last", today.isoformat())
|
||||
await self.storage.async_save()
|
||||
|
||||
@@ -613,17 +635,13 @@ class PointsMixin:
|
||||
if not part:
|
||||
continue
|
||||
if ":" not in part:
|
||||
raise ValueError(
|
||||
f"Invalid format '{part}' — use 'days:points' pairs, e.g. '7:10, 14:20'"
|
||||
)
|
||||
raise ValueError(f"Invalid format '{part}' — use 'days:points' pairs, e.g. '7:10, 14:20'")
|
||||
days_str, points_str = part.split(":", 1)
|
||||
try:
|
||||
days = int(days_str.strip())
|
||||
points = int(points_str.strip())
|
||||
except ValueError as err:
|
||||
raise ValueError(
|
||||
f"Invalid numbers in '{part}' — days and points must be whole numbers"
|
||||
) from err
|
||||
raise ValueError(f"Invalid numbers in '{part}' — days and points must be whole numbers") from err
|
||||
if days < 1:
|
||||
raise ValueError(f"Days must be at least 1, got {days}")
|
||||
if points < 1:
|
||||
@@ -718,7 +736,7 @@ class PointsMixin:
|
||||
today = now.date()
|
||||
effective_date = completion_date or today
|
||||
effective_date_str = effective_date.isoformat()
|
||||
last_date_str = getattr(child, 'last_completion_date', None)
|
||||
last_date_str = getattr(child, "last_completion_date", None)
|
||||
|
||||
# ── Weekend multiplier ──────────────────────────────────────────────
|
||||
# Applied to base chore points only, based on completion date
|
||||
@@ -740,7 +758,10 @@ class PointsMixin:
|
||||
if weekend_bonus > 0:
|
||||
_LOGGER.info(
|
||||
"Weekend multiplier (%.1fx) applied for %s: +%d bonus on top of %d",
|
||||
multiplier, child.name, weekend_bonus, points,
|
||||
multiplier,
|
||||
child.name,
|
||||
weekend_bonus,
|
||||
points,
|
||||
)
|
||||
# Log weekend bonus as a separate transaction for activity history
|
||||
transaction = PointsTransaction(
|
||||
@@ -758,9 +779,7 @@ class PointsMixin:
|
||||
# day is done. The in-flight completion (chore_id) is counted as done
|
||||
# since it may not be persisted yet at this point in the flow.
|
||||
if advance_streak and self._setting_enabled("streak_requires_all_chores"):
|
||||
if not self._all_due_chores_done(
|
||||
child.id, effective_date, include_rotation=True, extra_done=chore_id
|
||||
):
|
||||
if not self._all_due_chores_done(child.id, effective_date, include_rotation=True, extra_done=chore_id):
|
||||
advance_streak = False
|
||||
if advance_streak:
|
||||
streak_mode = self.storage.get_setting("streak_reset_mode", "reset")
|
||||
@@ -815,9 +834,7 @@ class PointsMixin:
|
||||
milestones_enabled = self.storage.get_setting("streak_milestones_enabled", "true") == "true"
|
||||
if advance_streak and milestones_enabled and child.current_streak > 0:
|
||||
# Parse custom milestone config
|
||||
milestone_setting = self.storage.get_setting(
|
||||
"streak_milestones", self.DEFAULT_STREAK_MILESTONES
|
||||
)
|
||||
milestone_setting = self.storage.get_setting("streak_milestones", self.DEFAULT_STREAK_MILESTONES)
|
||||
try:
|
||||
milestones = self.parse_milestone_setting(milestone_setting)
|
||||
except ValueError:
|
||||
@@ -836,7 +853,9 @@ class PointsMixin:
|
||||
reached_milestones.append((days, bonus_pts))
|
||||
_LOGGER.info(
|
||||
"Streak milestone %d days reached for %s: +%d bonus",
|
||||
days, child.name, bonus_pts,
|
||||
days,
|
||||
child.name,
|
||||
bonus_pts,
|
||||
)
|
||||
|
||||
child.streak_milestones_achieved = sorted(achieved)
|
||||
@@ -853,9 +872,7 @@ class PointsMixin:
|
||||
)
|
||||
self.storage.add_points_transaction(transaction)
|
||||
|
||||
self.storage.append_career_score_snapshot(
|
||||
child.id, effective_date.isoformat(), child.career_score
|
||||
)
|
||||
self.storage.append_career_score_snapshot(child.id, effective_date.isoformat(), child.career_score)
|
||||
await self._maybe_level_up(child)
|
||||
self.storage.update_child(child)
|
||||
|
||||
@@ -877,9 +894,11 @@ class PointsMixin:
|
||||
# A big streak is a celebration moment too — epic at 30+ days.
|
||||
for days, _bonus_pts in reached_milestones:
|
||||
await self._celebrate(
|
||||
child, "streak_milestone",
|
||||
child,
|
||||
"streak_milestone",
|
||||
f"{child.name} hit a {days}-day streak!",
|
||||
tier=3 if days >= 30 else 2, extra={"days": days},
|
||||
tier=3 if days >= 30 else 2,
|
||||
extra={"days": days},
|
||||
)
|
||||
return total_points
|
||||
|
||||
@@ -890,10 +909,7 @@ class PointsMixin:
|
||||
before = len(all_completions)
|
||||
|
||||
# Keep completions newer than cutoff OR unapproved (pending)
|
||||
to_keep = [
|
||||
c for c in all_completions
|
||||
if c.completed_at >= cutoff or not c.approved
|
||||
]
|
||||
to_keep = [c for c in all_completions if c.completed_at >= cutoff or not c.approved]
|
||||
|
||||
if len(to_keep) < before:
|
||||
kept_ids = {c.id for c in to_keep}
|
||||
@@ -906,10 +922,7 @@ class PointsMixin:
|
||||
if c.id not in kept_ids and getattr(c, "photo_url", ""):
|
||||
await photos.async_delete_photo(self.hass, c.photo_url)
|
||||
await self.async_refresh()
|
||||
_LOGGER.info(
|
||||
"Pruned %d completions older than %d days",
|
||||
before - len(to_keep), days
|
||||
)
|
||||
_LOGGER.info("Pruned %d completions older than %d days", before - len(to_keep), days)
|
||||
|
||||
# Penalty operations
|
||||
async def async_add_penalty(
|
||||
@@ -954,12 +967,17 @@ class PointsMixin:
|
||||
if not child:
|
||||
raise ValueError(f"Child {child_id} not found")
|
||||
await self.async_remove_points(child_id, penalty.points, reason=f"Penalty: {penalty.name}")
|
||||
self.hass.bus.async_fire("taskmate_penalty_applied", {
|
||||
"child_id": child.id, "child_name": child.name,
|
||||
"penalty_id": penalty.id, "penalty_name": penalty.name,
|
||||
"points": penalty.points,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_penalty_applied",
|
||||
{
|
||||
"child_id": child.id,
|
||||
"child_name": child.name,
|
||||
"penalty_id": penalty.id,
|
||||
"penalty_name": penalty.name,
|
||||
"points": penalty.points,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
# Bonus operations
|
||||
async def async_add_bonus(
|
||||
@@ -1004,16 +1022,25 @@ class PointsMixin:
|
||||
if not child:
|
||||
raise ValueError(f"Child {child_id} not found")
|
||||
await self.async_add_points(child_id, bonus.points, reason=f"Bonus: {bonus.name}")
|
||||
self.hass.bus.async_fire("taskmate_bonus_applied", {
|
||||
"child_id": child.id, "child_name": child.name,
|
||||
"bonus_id": bonus.id, "bonus_name": bonus.name,
|
||||
"points": bonus.points,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_bonus_applied",
|
||||
{
|
||||
"child_id": child.id,
|
||||
"child_name": child.name,
|
||||
"bonus_id": bonus.id,
|
||||
"bonus_name": bonus.name,
|
||||
"points": bonus.points,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
async def _async_notify_pending_approval(
|
||||
self, child_name: str, chore_name: str, points: int,
|
||||
completion_id: str | None = None, photo_url: str = "",
|
||||
self,
|
||||
child_name: str,
|
||||
chore_name: str,
|
||||
points: int,
|
||||
completion_id: str | None = None,
|
||||
photo_url: str = "",
|
||||
) -> None:
|
||||
await self.notifications.fire(
|
||||
"pending_chore_approval",
|
||||
@@ -1030,7 +1057,10 @@ class PointsMixin:
|
||||
)
|
||||
|
||||
async def _async_notify_pending_reward_claim(
|
||||
self, child_name: str, reward_name: str, cost: int,
|
||||
self,
|
||||
child_name: str,
|
||||
reward_name: str,
|
||||
cost: int,
|
||||
claim_id: str | None = None,
|
||||
) -> None:
|
||||
await self.notifications.fire(
|
||||
|
||||
@@ -6,6 +6,7 @@ current step advances them. Finishing the final step awards the quest's bonus
|
||||
points, fires a ``taskmate_quest_completed`` event + celebration, and either
|
||||
resets progress (repeatable quests) or marks the quest complete for that child.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -72,17 +73,19 @@ class QuestsMixin:
|
||||
step = int(prog.get("step", 0))
|
||||
total = len(quest.steps)
|
||||
done = step >= total
|
||||
out.append({
|
||||
"quest_id": quest.id,
|
||||
"name": quest.name,
|
||||
"icon": quest.icon,
|
||||
"total_steps": total,
|
||||
"step": min(step, total),
|
||||
"done": done,
|
||||
"times_completed": int(prog.get("completed_count", 0)),
|
||||
"bonus_points": quest.bonus_points,
|
||||
"next_chore_id": quest.steps[step] if not done and step < total else "",
|
||||
})
|
||||
out.append(
|
||||
{
|
||||
"quest_id": quest.id,
|
||||
"name": quest.name,
|
||||
"icon": quest.icon,
|
||||
"total_steps": total,
|
||||
"step": min(step, total),
|
||||
"done": done,
|
||||
"times_completed": int(prog.get("completed_count", 0)),
|
||||
"bonus_points": quest.bonus_points,
|
||||
"next_chore_id": quest.steps[step] if not done and step < total else "",
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
# ── Progression ──────────────────────────────────────────────────────
|
||||
@@ -129,24 +132,36 @@ class QuestsMixin:
|
||||
child.points += bonus
|
||||
child.total_points_earned += bonus
|
||||
child.career_score = child.total_points_earned - child.total_penalties_received
|
||||
self.storage.add_points_transaction(PointsTransaction(
|
||||
child_id=child.id, points=bonus,
|
||||
reason=f"Quest complete: {quest.name}", created_at=dt_util.now(),
|
||||
))
|
||||
self.storage.add_points_transaction(
|
||||
PointsTransaction(
|
||||
child_id=child.id,
|
||||
points=bonus,
|
||||
reason=f"Quest complete: {quest.name}",
|
||||
created_at=dt_util.now(),
|
||||
)
|
||||
)
|
||||
if hasattr(self, "_maybe_level_up"):
|
||||
await self._maybe_level_up(child)
|
||||
self.storage.update_child(child)
|
||||
|
||||
self.hass.bus.async_fire("taskmate_quest_completed", {
|
||||
"child_id": child.id, "child_name": child.name,
|
||||
"quest_id": quest.id, "quest_name": quest.name,
|
||||
"bonus": bonus, "timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_quest_completed",
|
||||
{
|
||||
"child_id": child.id,
|
||||
"child_name": child.name,
|
||||
"quest_id": quest.id,
|
||||
"quest_name": quest.name,
|
||||
"bonus": bonus,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
},
|
||||
)
|
||||
if hasattr(self, "_celebrate"):
|
||||
await self._celebrate(
|
||||
child, "quest_completed",
|
||||
child,
|
||||
"quest_completed",
|
||||
f"{child.name} completed the quest '{quest.name}'!",
|
||||
tier=3, extra={"quest_id": quest.id, "bonus": bonus},
|
||||
tier=3,
|
||||
extra={"quest_id": quest.id, "bonus": bonus},
|
||||
)
|
||||
|
||||
# Repeatable quests start over; one-shot quests stay complete.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Reward operations mixin for TaskMateCoordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -82,15 +83,11 @@ class RewardsMixin:
|
||||
if old and reward.cost < old.cost:
|
||||
self._refund_pool_excess(reward, "Pool refund (reward cost reduced)")
|
||||
became_unavailable = (
|
||||
self._reward_is_unavailable(reward)
|
||||
and old is not None
|
||||
and not self._reward_is_unavailable(old)
|
||||
self._reward_is_unavailable(reward) and old is not None and not self._reward_is_unavailable(old)
|
||||
)
|
||||
if became_unavailable:
|
||||
reason = (
|
||||
"Pool refund (reward expired)"
|
||||
if self._reward_is_expired(reward)
|
||||
else "Pool refund (reward sold out)"
|
||||
"Pool refund (reward expired)" if self._reward_is_expired(reward) else "Pool refund (reward sold out)"
|
||||
)
|
||||
self._refund_all_pool_allocations(reward, reason)
|
||||
await self.storage.async_save()
|
||||
@@ -140,8 +137,7 @@ class RewardsMixin:
|
||||
stays consistent with cost-reduction refunds.
|
||||
"""
|
||||
allocations = [
|
||||
a for a in self.storage.get_pool_allocations()
|
||||
if a.reward_id == reward.id and a.allocated_points > 0
|
||||
a for a in self.storage.get_pool_allocations() if a.reward_id == reward.id and a.allocated_points > 0
|
||||
]
|
||||
for alloc in allocations:
|
||||
self._apply_pool_refund(alloc, alloc.allocated_points, reward, reason)
|
||||
@@ -154,8 +150,7 @@ class RewardsMixin:
|
||||
until the combined total matches the cost.
|
||||
"""
|
||||
allocations = [
|
||||
a for a in self.storage.get_pool_allocations()
|
||||
if a.reward_id == reward.id and a.allocated_points > 0
|
||||
a for a in self.storage.get_pool_allocations() if a.reward_id == reward.id and a.allocated_points > 0
|
||||
]
|
||||
if not allocations:
|
||||
return
|
||||
@@ -173,13 +168,9 @@ class RewardsMixin:
|
||||
else:
|
||||
for alloc in allocations:
|
||||
if alloc.allocated_points > reward.cost:
|
||||
self._apply_pool_refund(
|
||||
alloc, alloc.allocated_points - reward.cost, reward, reason
|
||||
)
|
||||
self._apply_pool_refund(alloc, alloc.allocated_points - reward.cost, reward, reason)
|
||||
|
||||
def _apply_pool_refund(
|
||||
self, allocation: PoolAllocation, refund: int, reward: Reward, reason: str
|
||||
) -> None:
|
||||
def _apply_pool_refund(self, allocation: PoolAllocation, refund: int, reward: Reward, reason: str) -> None:
|
||||
"""Refund `refund` points from `allocation` back to the child's wallet.
|
||||
|
||||
Updates or removes the allocation record and writes an audit transaction.
|
||||
@@ -196,19 +187,23 @@ class RewardsMixin:
|
||||
if remaining <= 0:
|
||||
self.storage.remove_pool_allocation(allocation.child_id, allocation.reward_id)
|
||||
else:
|
||||
self.storage.upsert_pool_allocation(PoolAllocation(
|
||||
child_id=allocation.child_id,
|
||||
reward_id=allocation.reward_id,
|
||||
allocated_points=remaining,
|
||||
id=allocation.id,
|
||||
))
|
||||
self.storage.upsert_pool_allocation(
|
||||
PoolAllocation(
|
||||
child_id=allocation.child_id,
|
||||
reward_id=allocation.reward_id,
|
||||
allocated_points=remaining,
|
||||
id=allocation.id,
|
||||
)
|
||||
)
|
||||
|
||||
self.storage.add_points_transaction(PointsTransaction(
|
||||
child_id=allocation.child_id,
|
||||
points=refund,
|
||||
reason=f"{reason}: {reward.name}",
|
||||
created_at=dt_util.now(),
|
||||
))
|
||||
self.storage.add_points_transaction(
|
||||
PointsTransaction(
|
||||
child_id=allocation.child_id,
|
||||
points=refund,
|
||||
reason=f"{reason}: {reward.name}",
|
||||
created_at=dt_util.now(),
|
||||
)
|
||||
)
|
||||
|
||||
async def async_claim_reward(self, reward_id: str, child_id: str) -> RewardClaim:
|
||||
"""Child claims a reward — creates a pending claim awaiting parent approval.
|
||||
@@ -261,9 +256,7 @@ class RewardsMixin:
|
||||
available_points = child.points - committed
|
||||
|
||||
if available_points < effective_cost:
|
||||
raise ValueError(
|
||||
f"Not enough points. Need {effective_cost}, have {available_points} available"
|
||||
)
|
||||
raise ValueError(f"Not enough points. Need {effective_cost}, have {available_points} available")
|
||||
|
||||
claim = RewardClaim(
|
||||
reward_id=reward_id,
|
||||
@@ -287,7 +280,10 @@ class RewardsMixin:
|
||||
await self.storage.async_save()
|
||||
await self.async_refresh()
|
||||
await self._async_notify_pending_reward_claim(
|
||||
child.name, reward.name, reward.cost, claim_id=claim.id,
|
||||
child.name,
|
||||
reward.name,
|
||||
reward.cost,
|
||||
claim_id=claim.id,
|
||||
)
|
||||
return claim
|
||||
|
||||
@@ -297,6 +293,7 @@ class RewardsMixin:
|
||||
if period == "monthly":
|
||||
return today.replace(day=1)
|
||||
from datetime import timedelta
|
||||
|
||||
return today - timedelta(days=today.weekday()) # Monday of this week
|
||||
|
||||
def _spent_in_period(self, child_id: str) -> int:
|
||||
@@ -324,9 +321,7 @@ class RewardsMixin:
|
||||
if cap <= 0:
|
||||
return
|
||||
if self._spent_in_period(child_id) + cost > cap:
|
||||
raise ValueError(
|
||||
f"Spending cap reached: {cap} per period already used"
|
||||
)
|
||||
raise ValueError(f"Spending cap reached: {cap} per period already used")
|
||||
|
||||
async def async_approve_reward(self, claim_id: str) -> None:
|
||||
"""Approve a reward claim and deduct points from the child.
|
||||
@@ -371,7 +366,8 @@ class RewardsMixin:
|
||||
self._refund_pool_excess(reward, "Pool refund on redeem")
|
||||
if reward.is_jackpot:
|
||||
jackpot_allocs = [
|
||||
a for a in self.storage.get_pool_allocations()
|
||||
a
|
||||
for a in self.storage.get_pool_allocations()
|
||||
if a.reward_id == claim.reward_id and a.allocated_points > 0
|
||||
]
|
||||
for alloc in jackpot_allocs:
|
||||
@@ -381,9 +377,7 @@ class RewardsMixin:
|
||||
else:
|
||||
# Wallet mode: deduct directly from child.points
|
||||
if child.points < effective_cost:
|
||||
raise ValueError(
|
||||
f"Not enough points to approve. Need {effective_cost}, have {child.points}"
|
||||
)
|
||||
raise ValueError(f"Not enough points to approve. Need {effective_cost}, have {child.points}")
|
||||
child.points -= effective_cost
|
||||
self.storage.update_child(child)
|
||||
|
||||
@@ -393,9 +387,7 @@ class RewardsMixin:
|
||||
if reward.quantity == 0:
|
||||
# Last unit claimed — refund any points other children
|
||||
# still have earmarked for this reward's pool.
|
||||
self._refund_all_pool_allocations(
|
||||
reward, "Pool refund (reward sold out)"
|
||||
)
|
||||
self._refund_all_pool_allocations(reward, "Pool refund (reward sold out)")
|
||||
|
||||
claim.approved = True
|
||||
claim.approved_at = dt_util.now()
|
||||
@@ -405,20 +397,23 @@ class RewardsMixin:
|
||||
|
||||
# Dismiss the mobile approval push now this claim is reviewed.
|
||||
if getattr(self, "notifications", None):
|
||||
await self.notifications.clear_approval(
|
||||
"pending_reward_claim", claim_id
|
||||
)
|
||||
await self.notifications.clear_approval("pending_reward_claim", claim_id)
|
||||
|
||||
# Timed unlock (#678): allowlisted entity on, auto-off later.
|
||||
await self.async_start_unlock(reward, child)
|
||||
|
||||
self.hass.bus.async_fire("taskmate_reward_approved", {
|
||||
"child_id": child.id, "child_name": child.name,
|
||||
"reward_id": reward.id, "reward_name": reward.name,
|
||||
"claim_id": claim.id,
|
||||
"cost": effective_cost,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_reward_approved",
|
||||
{
|
||||
"child_id": child.id,
|
||||
"child_name": child.name,
|
||||
"reward_id": reward.id,
|
||||
"reward_name": reward.name,
|
||||
"claim_id": claim.id,
|
||||
"cost": effective_cost,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
if getattr(self, "badges", None):
|
||||
await self.badges.evaluate_for_child(claim.child_id, "reward_redeemed")
|
||||
@@ -435,23 +430,22 @@ class RewardsMixin:
|
||||
if claim:
|
||||
reward = self.get_reward(claim.reward_id)
|
||||
child = self.get_child(claim.child_id)
|
||||
self.hass.bus.async_fire("taskmate_reward_rejected", {
|
||||
"child_id": claim.child_id,
|
||||
"child_name": getattr(child, "name", ""),
|
||||
"reward_id": claim.reward_id,
|
||||
"reward_name": getattr(reward, "name", ""),
|
||||
"claim_id": claim.id,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_reward_rejected",
|
||||
{
|
||||
"child_id": claim.child_id,
|
||||
"child_name": getattr(child, "name", ""),
|
||||
"reward_id": claim.reward_id,
|
||||
"reward_name": getattr(reward, "name", ""),
|
||||
"claim_id": claim.id,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
},
|
||||
)
|
||||
# Dismiss the mobile approval push for this reviewed claim.
|
||||
if getattr(self, "notifications", None):
|
||||
await self.notifications.clear_approval(
|
||||
"pending_reward_claim", claim_id
|
||||
)
|
||||
await self.notifications.clear_approval("pending_reward_claim", claim_id)
|
||||
|
||||
async def async_allocate_points_to_pool(
|
||||
self, child_id: str, reward_id: str, points: int
|
||||
) -> PoolAllocation:
|
||||
async def async_allocate_points_to_pool(self, child_id: str, reward_id: str, points: int) -> PoolAllocation:
|
||||
"""Move `points` from a child's spendable balance into a reward pool.
|
||||
|
||||
Deducts immediately from child.points so the visible balance reflects the
|
||||
@@ -539,6 +533,7 @@ class RewardsMixin:
|
||||
``restock_last`` stamp guards against restocking twice in a day.
|
||||
"""
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
today = dt_util.now().date()
|
||||
today_iso = today.isoformat()
|
||||
changed = False
|
||||
@@ -577,8 +572,7 @@ class RewardsMixin:
|
||||
if not self._reward_is_expired(reward):
|
||||
continue
|
||||
allocations_before = [
|
||||
a for a in self.storage.get_pool_allocations()
|
||||
if a.reward_id == reward.id and a.allocated_points > 0
|
||||
a for a in self.storage.get_pool_allocations() if a.reward_id == reward.id and a.allocated_points > 0
|
||||
]
|
||||
if not allocations_before:
|
||||
continue
|
||||
@@ -586,7 +580,9 @@ class RewardsMixin:
|
||||
changed = True
|
||||
_LOGGER.info(
|
||||
"Reward '%s' expired on %s — refunded %d pool allocation(s)",
|
||||
reward.name, reward.expires_at, len(allocations_before),
|
||||
reward.name,
|
||||
reward.expires_at,
|
||||
len(allocations_before),
|
||||
)
|
||||
|
||||
if changed:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Template operations mixin for TaskMateCoordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -70,9 +71,7 @@ class TemplatesMixin:
|
||||
await self.async_refresh()
|
||||
return created_ids
|
||||
|
||||
async def async_save_template_from_chores(
|
||||
self, chore_ids: list[str], name: str, icon: str
|
||||
) -> str:
|
||||
async def async_save_template_from_chores(self, chore_ids: list[str], name: str, icon: str) -> str:
|
||||
"""Save existing chores as a custom template pack."""
|
||||
if not chore_ids:
|
||||
raise ValueError("At least one chore must be selected")
|
||||
@@ -97,9 +96,7 @@ class TemplatesMixin:
|
||||
await self.storage.async_save()
|
||||
return tpl_id
|
||||
|
||||
async def async_create_template(
|
||||
self, name: str, icon: str, chores: list[dict]
|
||||
) -> str:
|
||||
async def async_create_template(self, name: str, icon: str, chores: list[dict]) -> str:
|
||||
"""Create a new custom template from scratch."""
|
||||
if not chores:
|
||||
raise ValueError("Template must have at least one chore")
|
||||
@@ -156,16 +153,19 @@ class TemplatesMixin:
|
||||
for tpl in self.storage.get_custom_templates():
|
||||
if wanted and tpl.get("id") not in wanted:
|
||||
continue
|
||||
packed.append({
|
||||
"name": tpl.get("name", ""),
|
||||
"icon": tpl.get("icon", "mdi:clipboard-list-outline"),
|
||||
"chores": [
|
||||
{k: v for k, v in chore.items() if k in TEMPLATE_CHORE_FIELDS}
|
||||
for chore in tpl.get("chores", [])
|
||||
],
|
||||
})
|
||||
packed.append(
|
||||
{
|
||||
"name": tpl.get("name", ""),
|
||||
"icon": tpl.get("icon", "mdi:clipboard-list-outline"),
|
||||
"chores": [
|
||||
{k: v for k, v in chore.items() if k in TEMPLATE_CHORE_FIELDS}
|
||||
for chore in tpl.get("chores", [])
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
return {
|
||||
"format": self.PACK_FORMAT,
|
||||
"version": self.PACK_VERSION,
|
||||
@@ -191,8 +191,7 @@ class TemplatesMixin:
|
||||
raise ValueError("Pack version is not a number") from err
|
||||
if version > self.PACK_VERSION:
|
||||
raise ValueError(
|
||||
f"This pack needs a newer TaskMate (pack version {version}, "
|
||||
f"this one understands {self.PACK_VERSION})"
|
||||
f"This pack needs a newer TaskMate (pack version {version}, this one understands {self.PACK_VERSION})"
|
||||
)
|
||||
|
||||
templates = pack.get("templates")
|
||||
@@ -227,11 +226,13 @@ class TemplatesMixin:
|
||||
cleaned["name"] = chore_name[:200]
|
||||
chores.append(cleaned)
|
||||
|
||||
clean.append({
|
||||
"name": name[:120],
|
||||
"icon": str(entry.get("icon", "") or "mdi:clipboard-list-outline"),
|
||||
"chores": chores,
|
||||
})
|
||||
clean.append(
|
||||
{
|
||||
"name": name[:120],
|
||||
"icon": str(entry.get("icon", "") or "mdi:clipboard-list-outline"),
|
||||
"chores": chores,
|
||||
}
|
||||
)
|
||||
return clean
|
||||
|
||||
async def async_import_pack(self, pack: dict) -> dict:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Timed task operations mixin for TaskMateCoordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -41,9 +42,7 @@ class TimedMixin:
|
||||
# Check daily cap before resuming
|
||||
if chore.timed_max_daily_minutes > 0:
|
||||
if existing.total_seconds_today >= chore.timed_max_daily_minutes * 60:
|
||||
raise ValueError(
|
||||
f"Daily cap reached ({chore.timed_max_daily_minutes} min)"
|
||||
)
|
||||
raise ValueError(f"Daily cap reached ({chore.timed_max_daily_minutes} min)")
|
||||
existing.state = "running"
|
||||
existing.segments.append({"start": now.isoformat(), "end": None})
|
||||
self.storage.save_timed_session(existing)
|
||||
@@ -52,9 +51,7 @@ class TimedMixin:
|
||||
if chore.timed_max_daily_minutes > 0:
|
||||
old_session = self.storage.get_timed_session(chore_id, child_id, today)
|
||||
if old_session and old_session.total_seconds_today >= chore.timed_max_daily_minutes * 60:
|
||||
raise ValueError(
|
||||
f"Daily cap reached ({chore.timed_max_daily_minutes} min)"
|
||||
)
|
||||
raise ValueError(f"Daily cap reached ({chore.timed_max_daily_minutes} min)")
|
||||
session = TimedSession(
|
||||
chore_id=chore_id,
|
||||
child_id=child_id,
|
||||
@@ -139,7 +136,10 @@ class TimedMixin:
|
||||
|
||||
if chore.requires_approval:
|
||||
await self._async_notify_pending_approval(
|
||||
child.name, chore.name, pts, completion_id=completion.id,
|
||||
child.name,
|
||||
chore.name,
|
||||
pts,
|
||||
completion_id=completion.id,
|
||||
)
|
||||
|
||||
await self.async_refresh()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Data coordinator for TaskMate integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -107,11 +108,7 @@ class TaskMateCoordinator(
|
||||
resolved = tier if tier in DEFAULT_DIFFICULTY_MULTIPLIERS else DEFAULT_DIFFICULTY
|
||||
default = DEFAULT_DIFFICULTY_MULTIPLIERS[resolved]
|
||||
try:
|
||||
return float(
|
||||
self.storage.get_setting(
|
||||
f"difficulty_multiplier_{resolved}", str(default)
|
||||
)
|
||||
)
|
||||
return float(self.storage.get_setting(f"difficulty_multiplier_{resolved}", str(default)))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
@@ -145,12 +142,14 @@ class TaskMateCoordinator(
|
||||
continue
|
||||
if end < start:
|
||||
start, end = end, start
|
||||
periods.append({
|
||||
"id": str(entry.get("id") or "").strip() or start.isoformat(),
|
||||
"name": str(entry.get("name") or "").strip(),
|
||||
"start": start.isoformat(),
|
||||
"end": end.isoformat(),
|
||||
})
|
||||
periods.append(
|
||||
{
|
||||
"id": str(entry.get("id") or "").strip() or start.isoformat(),
|
||||
"name": str(entry.get("name") or "").strip(),
|
||||
"start": start.isoformat(),
|
||||
"end": end.isoformat(),
|
||||
}
|
||||
)
|
||||
return sorted(periods, key=lambda p: p["start"])
|
||||
|
||||
def active_vacation(self, on: date | None = None) -> dict | None:
|
||||
@@ -253,20 +252,20 @@ class TaskMateCoordinator(
|
||||
await self.async_refresh()
|
||||
|
||||
# ── Admin audit log ──────────────────────────────────────────────────
|
||||
async def async_record_audit(
|
||||
self, user_id: str, user_name: str, action: str, target: str = ""
|
||||
) -> None:
|
||||
async def async_record_audit(self, user_id: str, user_name: str, action: str, target: str = "") -> None:
|
||||
"""Record an admin config action in the audit log and persist it."""
|
||||
from .models import generate_id
|
||||
|
||||
self.storage.add_audit_entry({
|
||||
"id": generate_id(),
|
||||
"ts": dt_util.now().isoformat(),
|
||||
"user_id": user_id or "",
|
||||
"user_name": user_name or "",
|
||||
"action": action,
|
||||
"target": target or "",
|
||||
})
|
||||
self.storage.add_audit_entry(
|
||||
{
|
||||
"id": generate_id(),
|
||||
"ts": dt_util.now().isoformat(),
|
||||
"user_id": user_id or "",
|
||||
"user_name": user_name or "",
|
||||
"action": action,
|
||||
"target": target or "",
|
||||
}
|
||||
)
|
||||
await self.storage.async_save()
|
||||
|
||||
async def async_initialize(self) -> None:
|
||||
@@ -296,16 +295,12 @@ class TaskMateCoordinator(
|
||||
self.hass, self._async_midnight_streak_check, hour=0, minute=0, second=5
|
||||
)
|
||||
# Schedule daily history pruning at 00:01:00
|
||||
self._unsub_prune = async_track_time_change(
|
||||
self.hass, self._async_scheduled_prune, hour=0, minute=1, second=0
|
||||
)
|
||||
self._unsub_prune = async_track_time_change(self.hass, self._async_scheduled_prune, hour=0, minute=1, second=0)
|
||||
# Re-evaluate availability-aware chore assignments when any HA entity
|
||||
# state changes. The callback filters cheaply on entity id so only
|
||||
# relevant flips trigger a recompute.
|
||||
self._refresh_tracked_availability_entities()
|
||||
self._unsub_availability = self.hass.bus.async_listen(
|
||||
"state_changed", self._availability_state_changed
|
||||
)
|
||||
self._unsub_availability = self.hass.bus.async_listen("state_changed", self._availability_state_changed)
|
||||
# Surprise-bonus daily roll at 16:00 (opt-in; no-op unless enabled)
|
||||
self._unsub_surprise = async_track_time_change(
|
||||
self.hass, self._async_surprise_bonus_check, hour=16, minute=0, second=0
|
||||
@@ -353,10 +348,7 @@ class TaskMateCoordinator(
|
||||
done[comp.child_id] = done.get(comp.child_id, 0) + 1
|
||||
earned[comp.child_id] = earned.get(comp.child_id, 0) + (comp.points_awarded or 0)
|
||||
pts = self.storage.get_points_name()
|
||||
lines = [
|
||||
f"• {c.name}: {done.get(c.id, 0)} chores, {earned.get(c.id, 0)} {pts} earned"
|
||||
for c in children
|
||||
]
|
||||
lines = [f"• {c.name}: {done.get(c.id, 0)} chores, {earned.get(c.id, 0)} {pts} earned" for c in children]
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _async_send_monthly_report(self) -> None:
|
||||
@@ -367,10 +359,13 @@ class TaskMateCoordinator(
|
||||
summary = self._build_monthly_report(month_start, month_end)
|
||||
if not summary:
|
||||
return
|
||||
await self.notifications.fire("monthly_report", {
|
||||
"summary": summary,
|
||||
"month": month_start.strftime("%B %Y"),
|
||||
})
|
||||
await self.notifications.fire(
|
||||
"monthly_report",
|
||||
{
|
||||
"summary": summary,
|
||||
"month": month_start.strftime("%B %Y"),
|
||||
},
|
||||
)
|
||||
|
||||
def _build_monthly_report(self, month_start: date, month_end: date) -> str:
|
||||
"""Per-child recap for [month_start, month_end]: chores, points, level, best streak."""
|
||||
@@ -403,8 +398,7 @@ class TaskMateCoordinator(
|
||||
ym = dt_util.now().strftime("%Y-%m")
|
||||
pts = self.storage.get_season_points(ym)
|
||||
rows = [
|
||||
{"child_id": c.id, "name": c.name, "points": int(pts.get(c.id, 0))}
|
||||
for c in self.storage.get_children()
|
||||
{"child_id": c.id, "name": c.name, "points": int(pts.get(c.id, 0))} for c in self.storage.get_children()
|
||||
]
|
||||
rows.sort(key=lambda r: (-r["points"], r["name"].lower()))
|
||||
for i, r in enumerate(rows):
|
||||
@@ -434,13 +428,22 @@ class TaskMateCoordinator(
|
||||
await self.storage.async_save()
|
||||
name = str(self.storage.get_setting("family_goal_name", "") or "Family goal")
|
||||
reward = str(self.storage.get_setting("family_goal_reward", "") or "a treat")
|
||||
self.hass.bus.async_fire("taskmate_family_goal_reached", {
|
||||
"goal_name": name, "goal_reward": reward, "target": target,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
await self.notifications.fire("family_goal_reached", {
|
||||
"goal_name": name, "goal_reward": reward,
|
||||
})
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_family_goal_reached",
|
||||
{
|
||||
"goal_name": name,
|
||||
"goal_reward": reward,
|
||||
"target": target,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
},
|
||||
)
|
||||
await self.notifications.fire(
|
||||
"family_goal_reached",
|
||||
{
|
||||
"goal_name": name,
|
||||
"goal_reward": reward,
|
||||
},
|
||||
)
|
||||
|
||||
# ── Allowance payout ledger (FEAT-3) ─────────────────────────────────
|
||||
async def async_record_allowance_payout(self, child_id: str, points: int) -> dict:
|
||||
@@ -469,6 +472,7 @@ class TaskMateCoordinator(
|
||||
await self.async_remove_points(child_id, points, reason="Allowance payout")
|
||||
|
||||
from .models import generate_id
|
||||
|
||||
entry = {
|
||||
"id": generate_id(),
|
||||
"child_id": child_id,
|
||||
@@ -490,6 +494,7 @@ class TaskMateCoordinator(
|
||||
token = self.storage.get_setting("ics_token", "")
|
||||
if not token:
|
||||
import secrets
|
||||
|
||||
token = secrets.token_urlsafe(24)
|
||||
self.storage.set_setting("ics_token", token)
|
||||
await self.storage.async_save()
|
||||
@@ -498,6 +503,7 @@ class TaskMateCoordinator(
|
||||
async def async_regenerate_ics_token(self) -> str:
|
||||
"""Rotate the ICS feed token (invalidates existing subscriptions)."""
|
||||
import secrets
|
||||
|
||||
token = secrets.token_urlsafe(24)
|
||||
self.storage.set_setting("ics_token", token)
|
||||
await self.storage.async_save()
|
||||
@@ -514,22 +520,34 @@ class TaskMateCoordinator(
|
||||
if not winners:
|
||||
return
|
||||
top = winners[0]
|
||||
self.storage.add_season_champion({
|
||||
"month": ym,
|
||||
"child_id": top["child_id"],
|
||||
"child_name": top["name"],
|
||||
"points": top["points"],
|
||||
})
|
||||
self.storage.add_season_champion(
|
||||
{
|
||||
"month": ym,
|
||||
"child_id": top["child_id"],
|
||||
"child_name": top["name"],
|
||||
"points": top["points"],
|
||||
}
|
||||
)
|
||||
await self.storage.async_save()
|
||||
self.hass.bus.async_fire("taskmate_season_champion", {
|
||||
"month": ym, "child_id": top["child_id"], "child_name": top["name"],
|
||||
"points": top["points"], "timestamp": now.isoformat(),
|
||||
})
|
||||
await self.notifications.fire("season_champion", {
|
||||
"child_name": top["name"], "points": top["points"],
|
||||
"month": prev_end.strftime("%B %Y"),
|
||||
"points_name": self.storage.get_points_name(),
|
||||
})
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_season_champion",
|
||||
{
|
||||
"month": ym,
|
||||
"child_id": top["child_id"],
|
||||
"child_name": top["name"],
|
||||
"points": top["points"],
|
||||
"timestamp": now.isoformat(),
|
||||
},
|
||||
)
|
||||
await self.notifications.fire(
|
||||
"season_champion",
|
||||
{
|
||||
"child_name": top["name"],
|
||||
"points": top["points"],
|
||||
"month": prev_end.strftime("%B %Y"),
|
||||
"points_name": self.storage.get_points_name(),
|
||||
},
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_surprise_bonus_check(self, now: datetime) -> None:
|
||||
@@ -565,10 +583,15 @@ class TaskMateCoordinator(
|
||||
if pts <= 0:
|
||||
continue
|
||||
await self.async_add_points(child.id, pts, reason="Surprise bonus 🎉")
|
||||
self.hass.bus.async_fire("taskmate_surprise_bonus", {
|
||||
"child_id": child.id, "child_name": child.name,
|
||||
"points": pts, "timestamp": dt_util.now().isoformat(),
|
||||
})
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_surprise_bonus",
|
||||
{
|
||||
"child_id": child.id,
|
||||
"child_name": child.name,
|
||||
"points": pts,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
async def _async_backfill_career_history(self) -> None:
|
||||
"""Backfill career_score_history from completions and transactions.
|
||||
@@ -618,13 +641,12 @@ class TaskMateCoordinator(
|
||||
running = start_score
|
||||
for day in sorted_days:
|
||||
running += daily_net[day]
|
||||
self.storage.append_career_score_snapshot(
|
||||
child.id, day, running
|
||||
)
|
||||
self.storage.append_career_score_snapshot(child.id, day, running)
|
||||
needs_save = True
|
||||
_LOGGER.info(
|
||||
"Backfilled %d career history entries for %s",
|
||||
len(sorted_days), child.name,
|
||||
len(sorted_days),
|
||||
child.name,
|
||||
)
|
||||
|
||||
if needs_save:
|
||||
@@ -701,9 +723,7 @@ class TaskMateCoordinator(
|
||||
async def _async_sweep_orphan_photos(self) -> None:
|
||||
"""Delete evidence photos not referenced by any completion (SEC-2)."""
|
||||
referenced = [
|
||||
getattr(c, "photo_url", "")
|
||||
for c in self.storage.get_completions()
|
||||
if getattr(c, "photo_url", "")
|
||||
getattr(c, "photo_url", "") for c in self.storage.get_completions() if getattr(c, "photo_url", "")
|
||||
]
|
||||
removed = await photos.async_sweep_orphan_photos(self.hass, referenced)
|
||||
if removed:
|
||||
@@ -804,10 +824,34 @@ class TaskMateCoordinator(
|
||||
self.storage.remove_career_score_history_for_child(child_id)
|
||||
self.storage.remove_quest_progress_for_child(child_id)
|
||||
self.storage.remove_challenge_progress_for_child(child_id)
|
||||
# Remove child from chore assigned_to lists
|
||||
# Drop pending swap requests either side of this child (#785) — a
|
||||
# handover to or from a deleted child can never complete, and the
|
||||
# approval queue would render them as "?".
|
||||
self.storage.remove_swap_requests_for_child(child_id)
|
||||
# Remove child from chore assigned_to lists, and clear any approved swap
|
||||
# override that pointed at them so the chore isn't left assigned to a
|
||||
# child who no longer exists.
|
||||
for chore in self.storage.get_chores():
|
||||
dirty = False
|
||||
if child_id in chore.assigned_to:
|
||||
chore.assigned_to.remove(child_id)
|
||||
dirty = True
|
||||
if getattr(chore, "assignment_swap_child_id", "") == child_id:
|
||||
chore.assignment_swap_child_id = ""
|
||||
chore.assignment_swap_date = ""
|
||||
dirty = True
|
||||
if dirty:
|
||||
self.storage.update_chore(chore)
|
||||
# A rotation pointer left on the deleted child hides the chore from
|
||||
# everyone (#787): the child sensor includes a non-everyone chore only
|
||||
# when assignment_current_child_id matches, and a deleted id matches
|
||||
# nobody. Repoint at today's live pool rather than just blanking it, so
|
||||
# the chore reappears for the survivors now instead of at midnight.
|
||||
stale = [c for c in self.storage.get_chores() if getattr(c, "assignment_current_child_id", "") == child_id]
|
||||
if stale:
|
||||
daily = self._compute_daily_assignments()
|
||||
for chore in stale:
|
||||
chore.assignment_current_child_id = daily.get(chore.id, "")
|
||||
self.storage.update_chore(chore)
|
||||
await self.storage.async_save()
|
||||
await self.async_refresh()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Frontend registration for TaskMate custom cards."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -56,8 +57,8 @@ CARDS: Final = [
|
||||
# why blanket stale-cleanup was removed from async_register_cards).
|
||||
RETIRED_CARDS: Final = [
|
||||
"taskmate-task-groups-card.js", # removed #452
|
||||
"taskmate-templates-card.js", # removed #448
|
||||
"taskmate-reminders-card.js", # removed #450
|
||||
"taskmate-templates-card.js", # removed #448
|
||||
"taskmate-reminders-card.js", # removed #450
|
||||
]
|
||||
|
||||
# JS modules loaded on every HA frontend page (config flow sound preview).
|
||||
@@ -86,9 +87,7 @@ async def _async_get_version(hass: HomeAssistant) -> str:
|
||||
"""Get version from manifest.json for cache busting (async-safe)."""
|
||||
manifest_path = Path(__file__).parent / "manifest.json"
|
||||
try:
|
||||
content = await hass.async_add_executor_job(
|
||||
manifest_path.read_text, "utf-8"
|
||||
)
|
||||
content = await hass.async_add_executor_job(manifest_path.read_text, "utf-8")
|
||||
return json.loads(content).get("version", "1.0.0")
|
||||
except (OSError, json.JSONDecodeError, AttributeError):
|
||||
return "1.0.0"
|
||||
@@ -107,23 +106,31 @@ async def async_register_frontend(hass: HomeAssistant) -> None:
|
||||
_LOGGER.warning("www directory not found at %s", www_path)
|
||||
return
|
||||
|
||||
# Register the www folder as a static path
|
||||
await hass.http.async_register_static_paths(
|
||||
[StaticPathConfig(URL_BASE, str(www_path), False)]
|
||||
)
|
||||
# Register the www folder as a static path.
|
||||
#
|
||||
# This call is what sets our minimum Home Assistant version. Both
|
||||
# async_register_static_paths and StaticPathConfig landed in 2024.7.0 and do
|
||||
# not exist in 2024.6.0, so on anything older setup dies here with an
|
||||
# AttributeError rather than degrading — hence the 2024.7.0 floor in
|
||||
# hacs.json and README.md. Lowering that floor means adding a fallback to
|
||||
# the old (since-removed) hass.http.register_static_path.
|
||||
await hass.http.async_register_static_paths([StaticPathConfig(URL_BASE, str(www_path), False)])
|
||||
|
||||
_LOGGER.debug("Registered static path: %s -> %s", URL_BASE, www_path)
|
||||
|
||||
# Authenticated upload/serve endpoints for chore evidence photos.
|
||||
from .http_photos import async_register_photo_views
|
||||
|
||||
async_register_photo_views(hass)
|
||||
|
||||
# Admin-gated upload / authenticated serve for chore pictures (#750).
|
||||
from .http_images import async_register_image_views
|
||||
|
||||
async_register_image_views(hass)
|
||||
|
||||
# Token-gated ICS calendar feed (FEAT-10).
|
||||
from .http_calendar import async_register_calendar_view
|
||||
|
||||
async_register_calendar_view(hass)
|
||||
|
||||
# Register global JS modules (loaded on all pages, including config flow)
|
||||
@@ -205,9 +212,7 @@ async def async_register_cards(hass: HomeAssistant) -> None:
|
||||
|
||||
if card_url not in existing:
|
||||
# Card not registered yet — add it
|
||||
await resources.async_create_item(
|
||||
{"url": versioned_url, "res_type": "module"}
|
||||
)
|
||||
await resources.async_create_item({"url": versioned_url, "res_type": "module"})
|
||||
_LOGGER.info("TaskMate: added resource: %s", versioned_url)
|
||||
else:
|
||||
item = existing[card_url]
|
||||
@@ -220,7 +225,8 @@ async def async_register_cards(hass: HomeAssistant) -> None:
|
||||
)
|
||||
_LOGGER.info(
|
||||
"TaskMate: updated resource: %s -> %s",
|
||||
current_url, versioned_url,
|
||||
current_url,
|
||||
versioned_url,
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug("TaskMate: resource up to date: %s", versioned_url)
|
||||
@@ -237,13 +243,12 @@ async def async_register_cards(hass: HomeAssistant) -> None:
|
||||
continue
|
||||
try:
|
||||
await resources.async_delete_item(item["id"])
|
||||
_LOGGER.info(
|
||||
"TaskMate: removed retired resource: %s", item.get("url")
|
||||
)
|
||||
_LOGGER.info("TaskMate: removed retired resource: %s", item.get("url"))
|
||||
except (AttributeError, KeyError, TypeError, OSError) as err:
|
||||
_LOGGER.warning(
|
||||
"TaskMate: could not remove retired resource %s: %s",
|
||||
item.get("url"), err,
|
||||
item.get("url"),
|
||||
err,
|
||||
)
|
||||
|
||||
except (AttributeError, KeyError, TypeError, OSError) as err:
|
||||
|
||||
@@ -5,6 +5,7 @@ is public-by-URL and authenticated by an unguessable per-instance token in the
|
||||
``?token=`` query param (compared in constant time). It serves a read-only feed
|
||||
of upcoming chores; no mutation is possible.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
@@ -28,6 +29,7 @@ HTTP_CAL_REGISTERED = "calendar_http_registered"
|
||||
|
||||
def _get_coordinator(hass: HomeAssistant):
|
||||
from .coordinator import TaskMateCoordinator
|
||||
|
||||
for value in hass.data.get(DOMAIN, {}).values():
|
||||
if isinstance(value, TaskMateCoordinator):
|
||||
return value
|
||||
|
||||
@@ -8,6 +8,7 @@ Two views, both auth-gated by ``HomeAssistantView`` (so photos are never public)
|
||||
Pure path/validation logic lives in :mod:`.photos` (unit-tested); this module is
|
||||
the thin aiohttp wrapper, verified on the dev HA instance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -37,9 +38,7 @@ class TaskMatePhotoUploadView(HomeAssistantView):
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
# Cheap pre-check on the declared length before reading the body.
|
||||
if request.content_length and request.content_length > photos.MAX_UPLOAD_BYTES:
|
||||
return self.json_message(
|
||||
"File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE
|
||||
)
|
||||
return self.json_message("File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE)
|
||||
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
@@ -61,22 +60,16 @@ class TaskMatePhotoUploadView(HomeAssistantView):
|
||||
break
|
||||
data.extend(chunk)
|
||||
if len(data) > photos.MAX_UPLOAD_BYTES:
|
||||
return self.json_message(
|
||||
"File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE
|
||||
)
|
||||
return self.json_message("File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE)
|
||||
|
||||
ext = photos.detect_image_ext(bytes(data))
|
||||
if ext is None:
|
||||
return self.json_message("Not a valid image", HTTPStatus.BAD_REQUEST)
|
||||
|
||||
# DoS guard: reject if the photo store is already at its disk budget.
|
||||
used = await self.hass.async_add_executor_job(
|
||||
photos.total_photos_bytes, self.hass
|
||||
)
|
||||
used = await self.hass.async_add_executor_job(photos.total_photos_bytes, self.hass)
|
||||
if used + len(data) > photos.MAX_TOTAL_BYTES:
|
||||
return self.json_message(
|
||||
"Photo storage full", HTTPStatus.INSUFFICIENT_STORAGE
|
||||
)
|
||||
return self.json_message("Photo storage full", HTTPStatus.INSUFFICIENT_STORAGE)
|
||||
|
||||
name = f"{uuid.uuid4().hex}.{ext}"
|
||||
directory = photos.photos_path(self.hass)
|
||||
@@ -90,9 +83,7 @@ class TaskMatePhotoUploadView(HomeAssistantView):
|
||||
await self.hass.async_add_executor_job(_write)
|
||||
except OSError as err:
|
||||
_LOGGER.error("Failed to store evidence photo: %s", err)
|
||||
return self.json_message(
|
||||
"Could not store photo", HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
)
|
||||
return self.json_message("Could not store photo", HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||
|
||||
return self.json({"photo_url": f"{photos.URL_PREFIX}/{name}"})
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Pure-ish helpers that turn the chore calendar projection into an RFC 5545 feed a
|
||||
calendar app (Google/Apple/Outlook) can subscribe to. Token auth + the HTTP view
|
||||
live in ``http_calendar.py``; this module only builds text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
@@ -14,13 +15,7 @@ PRODID = "-//TaskMate//Chores//EN"
|
||||
|
||||
def _escape(text: str) -> str:
|
||||
"""Escape a value per RFC 5545 (backslash, comma, semicolon, newline)."""
|
||||
return (
|
||||
str(text)
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\n", "\\n")
|
||||
.replace(",", "\\,")
|
||||
.replace(";", "\\;")
|
||||
)
|
||||
return str(text).replace("\\", "\\\\").replace("\n", "\\n").replace(",", "\\,").replace(";", "\\;")
|
||||
|
||||
|
||||
def _fold(line: str) -> str:
|
||||
@@ -97,31 +92,39 @@ def build_chore_events(coordinator, start_day: date, end_day: date) -> list[dict
|
||||
for chore in chores:
|
||||
if not _chore_applies_to_child(coordinator, chore, child.id, day):
|
||||
continue
|
||||
window = coordinator._time_category_window(
|
||||
getattr(chore, "time_category", "anytime"), day
|
||||
)
|
||||
window = coordinator._time_category_window(getattr(chore, "time_category", "anytime"), day)
|
||||
summary = f"{chore.name} — {child.name}"
|
||||
desc = _chore_description(chore)
|
||||
if window is None:
|
||||
events.append({
|
||||
"uid": make_uid(chore.id, child.id, day.isoformat(), "allday"),
|
||||
"summary": summary, "description": desc,
|
||||
"start": day, "end": day + timedelta(days=1), "all_day": True,
|
||||
})
|
||||
events.append(
|
||||
{
|
||||
"uid": make_uid(chore.id, child.id, day.isoformat(), "allday"),
|
||||
"summary": summary,
|
||||
"description": desc,
|
||||
"start": day,
|
||||
"end": day + timedelta(days=1),
|
||||
"all_day": True,
|
||||
}
|
||||
)
|
||||
else:
|
||||
start_dt, end_dt = window
|
||||
events.append({
|
||||
"uid": make_uid(chore.id, child.id, day.isoformat(), "timed"),
|
||||
"summary": summary, "description": desc,
|
||||
"start": _ensure_aware(start_dt),
|
||||
"end": _ensure_aware(end_dt), "all_day": False,
|
||||
})
|
||||
events.append(
|
||||
{
|
||||
"uid": make_uid(chore.id, child.id, day.isoformat(), "timed"),
|
||||
"summary": summary,
|
||||
"description": desc,
|
||||
"start": _ensure_aware(start_dt),
|
||||
"end": _ensure_aware(end_dt),
|
||||
"all_day": False,
|
||||
}
|
||||
)
|
||||
day += timedelta(days=1)
|
||||
return events
|
||||
|
||||
|
||||
def _ensure_aware(dt: datetime) -> datetime:
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)
|
||||
return dt
|
||||
|
||||
@@ -8,6 +8,7 @@ the example sentences in ``custom_sentences/<lang>/taskmate.yaml`` into your HA
|
||||
config (see custom_sentences/README.md). The speech-building logic is kept in
|
||||
pure helpers so it is unit-testable without the conversation stack.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -27,6 +28,7 @@ INTENT_POINTS = "TaskMatePoints"
|
||||
|
||||
def _get_coordinator(hass: HomeAssistant):
|
||||
from .coordinator import TaskMateCoordinator
|
||||
|
||||
for value in hass.data.get(DOMAIN, {}).values():
|
||||
if isinstance(value, TaskMateCoordinator):
|
||||
return value
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
"iot_class": "calculated",
|
||||
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
|
||||
"requirements": [],
|
||||
"version": "5.1.0"
|
||||
"version": "5.1.1"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Data models for TaskMate integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -24,6 +25,7 @@ def generate_id() -> str:
|
||||
def dt_util_now_iso() -> str:
|
||||
"""Current local time as an ISO string (module-level so dataclass defaults can use it)."""
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
return dt_util.now().isoformat()
|
||||
|
||||
|
||||
@@ -172,7 +174,7 @@ class Child:
|
||||
notify_service: str | None = None
|
||||
linked_user_id: str = "" # HA user id; when set, only that user (or an admin) may self-serve as this child
|
||||
quiet_hours_start: str = "" # "HH:MM" — start of do-not-disturb window; empty = no quiet hours
|
||||
quiet_hours_end: str = "" # "HH:MM" — end of do-not-disturb window; start>end means overnight
|
||||
quiet_hours_end: str = "" # "HH:MM" — end of do-not-disturb window; start>end means overnight
|
||||
level: int = 1 # cached XP level (derived from total_points_earned)
|
||||
# Guest profiles (#690): a visiting cousin gets a temporary child that
|
||||
# expires on its own and stays out of the family leaderboard.
|
||||
@@ -265,7 +267,9 @@ class Chore:
|
||||
# Optional uploaded photograph (#750). Takes precedence over `icon` at
|
||||
# every render site; stored as a /api/taskmate/image/<name> URL.
|
||||
image_url: str = ""
|
||||
difficulty: str = "medium" # easy | medium | hard — scales awarded points by the tier multiplier (medium = ×1.0 baseline)
|
||||
difficulty: str = (
|
||||
"medium" # easy | medium | hard — scales awarded points by the tier multiplier (medium = ×1.0 baseline)
|
||||
)
|
||||
# Scheduling
|
||||
# schedule_mode: "specific_days" = show on selected days of week (Mode A)
|
||||
# "recurring" = rolling window recurrence (Mode B)
|
||||
@@ -273,7 +277,7 @@ class Chore:
|
||||
due_days: list[str] = field(default_factory=list) # Mode A: days to show chore
|
||||
# Mode B fields
|
||||
recurrence: str = "weekly" # every_2_days | weekly | every_2_weeks | monthly | every_3_months | every_6_months
|
||||
recurrence_day: str = "" # optional: which day of week for weekly/every_2_weeks
|
||||
recurrence_day: str = "" # optional: which day of week for weekly/every_2_weeks
|
||||
recurrence_start: str = "" # optional: ISO date anchor for every_2_days
|
||||
first_occurrence_mode: str = "available_immediately" # available_immediately | wait_for_first_occurrence
|
||||
# Dynamic visibility
|
||||
@@ -293,7 +297,9 @@ class Chore:
|
||||
# One-shot chore fields
|
||||
enabled: bool = True # False = soft-disabled (completed or expired)
|
||||
disabled_for: list[str] = field(default_factory=list) # Child IDs this chore is disabled for
|
||||
depends_on: list[str] = field(default_factory=list) # Chore IDs that must be approved-completed today before this is available
|
||||
depends_on: list[str] = field(
|
||||
default_factory=list
|
||||
) # Chore IDs that must be approved-completed today before this is available
|
||||
created_date: str = "" # ISO date for one-shot expiry, e.g. "2026-04-16"
|
||||
expires_on: str = "" # optional ISO end date; chore auto-disables the day after
|
||||
# Reactive chores (#674): a short-lived chore raised by an automation, e.g.
|
||||
@@ -316,11 +322,20 @@ class Chore:
|
||||
# Dynamic assignment (sibling rotation)
|
||||
assignment_mode: str = "everyone" # everyone | alternating | random
|
||||
assignment_rotation_anchor: str = "" # ISO date; day-0 of the rotation for alternating
|
||||
assignment_current_child_id: str = "" # cached active child ID for today (computed at midnight and on create/update)
|
||||
assignment_current_child_id: str = (
|
||||
"" # cached active child ID for today (computed at midnight and on create/update)
|
||||
)
|
||||
require_availability: bool = False # When True, skip children whose availability entity says they're unavailable
|
||||
# Skip state (ephemeral: cleared at midnight when skip_date != today)
|
||||
skip_date: str = "" # ISO date the skip applies to ("" = no active skip)
|
||||
skip_count: int = 0 # number of times skipped today; added to rotation index
|
||||
# Approved sibling swap (ephemeral, same shape as the skip pair above).
|
||||
# This — not assignment_current_child_id — is the override every read-time
|
||||
# eligibility gate consults, because assignment_current_child_id is itself
|
||||
# *written from* _compute_active_children at midnight; reading it back there
|
||||
# would pin the rotation to whoever it last cached (#781).
|
||||
assignment_swap_child_id: str = "" # child the chore was swapped to
|
||||
assignment_swap_date: str = "" # ISO date the swap applies to ("" = no swap)
|
||||
# Calendar publish: list of HA calendar entity ids to mirror the chore to. Any number supported.
|
||||
publish_calendar_entities: list[str] = field(default_factory=list)
|
||||
# ISO dates already written to the configured calendars. Used for both
|
||||
@@ -392,6 +407,8 @@ class Chore:
|
||||
require_availability=data.get("require_availability", False),
|
||||
skip_date=data.get("skip_date", ""),
|
||||
skip_count=int(data.get("skip_count", 0) or 0),
|
||||
assignment_swap_child_id=data.get("assignment_swap_child_id", ""),
|
||||
assignment_swap_date=data.get("assignment_swap_date", ""),
|
||||
publish_calendar_entities=list(data.get("publish_calendar_entities", [])),
|
||||
# Back-compat: old records stored a single ISO date in
|
||||
# `publish_calendar_last_date`. Seed the new list with it so we
|
||||
@@ -456,6 +473,8 @@ class Chore:
|
||||
"require_availability": self.require_availability,
|
||||
"skip_date": self.skip_date,
|
||||
"skip_count": self.skip_count,
|
||||
"assignment_swap_child_id": self.assignment_swap_child_id,
|
||||
"assignment_swap_date": self.assignment_swap_date,
|
||||
"publish_calendar_entities": self.publish_calendar_entities,
|
||||
"publish_calendar_published_dates": self.publish_calendar_published_dates,
|
||||
"bonus_subtasks": [b.to_dict() for b in self.bonus_subtasks],
|
||||
@@ -561,7 +580,7 @@ class Quest:
|
||||
name: str
|
||||
description: str = ""
|
||||
icon: str = "mdi:map-marker-path"
|
||||
steps: list[str] = field(default_factory=list) # ordered chore IDs
|
||||
steps: list[str] = field(default_factory=list) # ordered chore IDs
|
||||
bonus_points: int = 25
|
||||
assigned_to: list[str] = field(default_factory=list) # child IDs; empty = all
|
||||
repeatable: bool = False
|
||||
@@ -609,8 +628,8 @@ class Challenge:
|
||||
name: str
|
||||
description: str = ""
|
||||
icon: str = "mdi:trophy-outline"
|
||||
scope: str = "daily" # daily | weekly
|
||||
metric: str = "chores" # chores | points
|
||||
scope: str = "daily" # daily | weekly
|
||||
metric: str = "chores" # chores | points
|
||||
target: int = 3
|
||||
bonus_points: int = 15
|
||||
assigned_to: list[str] = field(default_factory=list) # child IDs; empty = all
|
||||
@@ -708,8 +727,8 @@ class MandatoryMiss:
|
||||
|
||||
chore_id: str
|
||||
child_id: str
|
||||
due_date: str # ISO date the chore was missed
|
||||
period_id: str # the window that closed ("anytime" for all-day)
|
||||
due_date: str # ISO date the chore was missed
|
||||
period_id: str # the window that closed ("anytime" for all-day)
|
||||
penalty_points: int = 0
|
||||
postpone_count: int = 0
|
||||
escalation_stage: int = 0 # 0=none 1=nudged 2=reminded 3=parent-alerted (FEAT-6)
|
||||
@@ -1207,10 +1226,7 @@ class NotificationConfig:
|
||||
return cls(
|
||||
type_id=data.get("type_id", ""),
|
||||
master_enabled=bool(data.get("master_enabled", False)),
|
||||
routes={
|
||||
rid: NotificationRoute.from_dict(rdata)
|
||||
for rid, rdata in raw_routes.items()
|
||||
},
|
||||
routes={rid: NotificationRoute.from_dict(rdata) for rid, rdata in raw_routes.items()},
|
||||
nav_url=data.get("nav_url", "") or "",
|
||||
)
|
||||
|
||||
@@ -1231,8 +1247,8 @@ class CustomNotification:
|
||||
|
||||
name: str
|
||||
message_template: str
|
||||
time: str # "HH:MM"
|
||||
day_mask: int = 0b1111111 # bit0=Mon … bit6=Sun
|
||||
time: str # "HH:MM"
|
||||
day_mask: int = 0b1111111 # bit0=Mon … bit6=Sun
|
||||
recipient_ids: list[str] = field(default_factory=list)
|
||||
enabled: bool = True
|
||||
id: str = field(default_factory=generate_id)
|
||||
|
||||
@@ -4,6 +4,7 @@ Lets the points-bearing knobs be read and changed from automations/scripts and
|
||||
the HA UI without a service call. Values are persisted through the same
|
||||
settings store the panel uses, so panel and entity stay in sync.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.number import NumberEntity, NumberMode
|
||||
@@ -23,9 +24,7 @@ _NUMBERS = [
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||
"""Set up the TaskMate setting-number entities."""
|
||||
coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(TaskMateSettingNumber(coordinator, entry, *cfg) for cfg in _NUMBERS)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Sidebar panel registration for the TaskMate admin UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
@@ -7,6 +7,7 @@ real HA install. The aiohttp views live in ``http_photos.py``.
|
||||
Photos are stored as ``<32 hex>.<ext>`` under ``<config>/taskmate_photos`` and
|
||||
served (auth-gated) at ``/api/taskmate/photo/<name>``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -86,7 +87,7 @@ def is_taskmate_photo_url(photo_url: str) -> bool:
|
||||
prefix = URL_PREFIX + "/"
|
||||
if not photo_url.startswith(prefix):
|
||||
return False
|
||||
return bool(FILENAME_RE.match(photo_url[len(prefix):]))
|
||||
return bool(FILENAME_RE.match(photo_url[len(prefix) :]))
|
||||
|
||||
|
||||
def photo_file_for_url(hass, photo_url: str) -> Path | None:
|
||||
@@ -101,7 +102,7 @@ def photo_file_for_url(hass, photo_url: str) -> Path | None:
|
||||
prefix = URL_PREFIX + "/"
|
||||
if not photo_url.startswith(prefix):
|
||||
return None
|
||||
name = photo_url[len(prefix):]
|
||||
name = photo_url[len(prefix) :]
|
||||
if not FILENAME_RE.match(name):
|
||||
return None
|
||||
return photos_path(hass) / name
|
||||
@@ -181,10 +182,7 @@ async def async_sweep_orphan_photos(hass, referenced_urls, max_age_hours: int =
|
||||
Returns the number of files deleted.
|
||||
"""
|
||||
prefix = URL_PREFIX + "/"
|
||||
referenced = {
|
||||
url[len(prefix):] for url in referenced_urls
|
||||
if url and url.startswith(prefix)
|
||||
}
|
||||
referenced = {url[len(prefix) :] for url in referenced_urls if url and url.startswith(prefix)}
|
||||
directory = photos_path(hass)
|
||||
|
||||
def _sweep() -> int:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Select platform — expose key choice TaskMate settings as entities (FEAT-9)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
@@ -14,13 +15,17 @@ from .coordinator import TaskMateCoordinator
|
||||
# (setting_key, translation_key, options, default, icon)
|
||||
_SELECTS = [
|
||||
("streak_reset_mode", "streak_reset_mode", ["reset", "pause"], "reset", "mdi:restart"),
|
||||
("card_design", "card_design", ["classic", "playroom", "console", "cleanpro", "accessible"], "classic", "mdi:palette"),
|
||||
(
|
||||
"card_design",
|
||||
"card_design",
|
||||
["classic", "playroom", "console", "cleanpro", "accessible"],
|
||||
"classic",
|
||||
"mdi:palette",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||
"""Set up the TaskMate setting-select entities."""
|
||||
coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(TaskMateSettingSelect(coordinator, entry, *cfg) for cfg in _SELECTS)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Sensor platform for TaskMate integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -85,9 +86,7 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict:
|
||||
for comp in pending_completions:
|
||||
chore = chore_lookup.get(comp.chore_id)
|
||||
if chore:
|
||||
pending_points_by_child[comp.child_id] = (
|
||||
pending_points_by_child.get(comp.child_id, 0) + chore.points
|
||||
)
|
||||
pending_points_by_child[comp.child_id] = pending_points_by_child.get(comp.child_id, 0) + chore.points
|
||||
|
||||
# Committed points per child (reward claims awaiting approval = points reserved).
|
||||
# Pool-mode pending claims are skipped because their cost was already deducted
|
||||
@@ -98,9 +97,7 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict:
|
||||
continue
|
||||
reward = reward_lookup.get(rc.reward_id)
|
||||
if reward:
|
||||
committed_points_by_child[rc.child_id] = (
|
||||
committed_points_by_child.get(rc.child_id, 0) + reward.cost
|
||||
)
|
||||
committed_points_by_child[rc.child_id] = committed_points_by_child.get(rc.child_id, 0) + reward.cost
|
||||
|
||||
# Pool allocation lookups for v3.0 pool mode.
|
||||
pool_by_child_reward: dict[str, dict[str, int]] = {}
|
||||
@@ -108,12 +105,8 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict:
|
||||
total_allocated_by_child: dict[str, int] = {}
|
||||
for pa in pool_alloc_objs:
|
||||
pool_by_child_reward.setdefault(pa.child_id, {})[pa.reward_id] = pa.allocated_points
|
||||
pool_total_by_reward[pa.reward_id] = (
|
||||
pool_total_by_reward.get(pa.reward_id, 0) + pa.allocated_points
|
||||
)
|
||||
total_allocated_by_child[pa.child_id] = (
|
||||
total_allocated_by_child.get(pa.child_id, 0) + pa.allocated_points
|
||||
)
|
||||
pool_total_by_reward[pa.reward_id] = pool_total_by_reward.get(pa.reward_id, 0) + pa.allocated_points
|
||||
total_allocated_by_child[pa.child_id] = total_allocated_by_child.get(pa.child_id, 0) + pa.allocated_points
|
||||
|
||||
common = {
|
||||
"data_id": data_id,
|
||||
@@ -151,51 +144,57 @@ def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> l
|
||||
for c in children:
|
||||
committed_amount = committed.get(c.id, 0)
|
||||
lvl = coordinator.level_info(c)
|
||||
summary.append({
|
||||
"level": lvl["level"],
|
||||
"level_progress": lvl["progress"],
|
||||
"level_target": lvl["target"],
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"points": c.points,
|
||||
"pending_points": pending.get(c.id, 0),
|
||||
# Guest profiles (#690): cards filter these out of competitive views.
|
||||
**({"is_guest": True, "guest_expires_on": getattr(c, "guest_expires_on", "")}
|
||||
if getattr(c, "is_guest", False) else {}),
|
||||
# Chore roulette (#677): today's pick + spins left, so the card can
|
||||
# show the result and disable the button once the allowance is used.
|
||||
**(
|
||||
{
|
||||
"roulette": {
|
||||
**(coordinator.roulette_selection(c.id) or {}),
|
||||
"spins_left": coordinator.roulette_spins_left(c.id),
|
||||
summary.append(
|
||||
{
|
||||
"level": lvl["level"],
|
||||
"level_progress": lvl["progress"],
|
||||
"level_target": lvl["target"],
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"points": c.points,
|
||||
"pending_points": pending.get(c.id, 0),
|
||||
# Guest profiles (#690): cards filter these out of competitive views.
|
||||
**(
|
||||
{"is_guest": True, "guest_expires_on": getattr(c, "guest_expires_on", "")}
|
||||
if getattr(c, "is_guest", False)
|
||||
else {}
|
||||
),
|
||||
# Chore roulette (#677): today's pick + spins left, so the card can
|
||||
# show the result and disable the button once the allowance is used.
|
||||
**(
|
||||
{
|
||||
"roulette": {
|
||||
**(coordinator.roulette_selection(c.id) or {}),
|
||||
"spins_left": coordinator.roulette_spins_left(c.id),
|
||||
}
|
||||
}
|
||||
}
|
||||
if coordinator.roulette_enabled() else {}
|
||||
),
|
||||
"committed_points": committed_amount,
|
||||
"allocated_points": allocated.get(c.id, 0),
|
||||
# Allocations were deducted from child.points already, so spendable
|
||||
# only needs to account for pending-claim commitments.
|
||||
"spendable_balance": max(0, c.points - committed_amount),
|
||||
"chore_order": c.chore_order,
|
||||
"current_streak": getattr(c, 'current_streak', 0) or 0,
|
||||
"best_streak": getattr(c, 'best_streak', 0) or 0,
|
||||
"season_points": int(season.get(c.id, 0)),
|
||||
"total_points_earned": getattr(c, 'total_points_earned', 0) or 0,
|
||||
"total_chores_completed": getattr(c, 'total_chores_completed', 0) or 0,
|
||||
"avatar": getattr(c, 'avatar', 'mdi:account-circle') or 'mdi:account-circle',
|
||||
"last_completion_date": getattr(c, 'last_completion_date', None),
|
||||
"streak_paused": getattr(c, 'streak_paused', False),
|
||||
"on_vacation": coordinator._is_child_on_vacation(c),
|
||||
"streak_milestones_achieved": getattr(c, 'streak_milestones_achieved', None) or [],
|
||||
"awarded_perfect_weeks": getattr(c, 'awarded_perfect_weeks', None) or [],
|
||||
"career_score": getattr(c, 'career_score', 0) or 0,
|
||||
"total_penalties_received": getattr(c, 'total_penalties_received', 0) or 0,
|
||||
"quests": coordinator.quest_progress_for_child(c.id),
|
||||
"avatar_options": coordinator.avatar_options_for_child(c),
|
||||
"challenges": coordinator.challenge_progress_for_child(c.id),
|
||||
})
|
||||
if coordinator.roulette_enabled()
|
||||
else {}
|
||||
),
|
||||
"committed_points": committed_amount,
|
||||
"allocated_points": allocated.get(c.id, 0),
|
||||
# Allocations were deducted from child.points already, so spendable
|
||||
# only needs to account for pending-claim commitments.
|
||||
"spendable_balance": max(0, c.points - committed_amount),
|
||||
"chore_order": c.chore_order,
|
||||
"current_streak": getattr(c, "current_streak", 0) or 0,
|
||||
"best_streak": getattr(c, "best_streak", 0) or 0,
|
||||
"season_points": int(season.get(c.id, 0)),
|
||||
"total_points_earned": getattr(c, "total_points_earned", 0) or 0,
|
||||
"total_chores_completed": getattr(c, "total_chores_completed", 0) or 0,
|
||||
"avatar": getattr(c, "avatar", "mdi:account-circle") or "mdi:account-circle",
|
||||
"last_completion_date": getattr(c, "last_completion_date", None),
|
||||
"streak_paused": getattr(c, "streak_paused", False),
|
||||
"on_vacation": coordinator._is_child_on_vacation(c),
|
||||
"streak_milestones_achieved": getattr(c, "streak_milestones_achieved", None) or [],
|
||||
"awarded_perfect_weeks": getattr(c, "awarded_perfect_weeks", None) or [],
|
||||
"career_score": getattr(c, "career_score", 0) or 0,
|
||||
"total_penalties_received": getattr(c, "total_penalties_received", 0) or 0,
|
||||
"quests": coordinator.quest_progress_for_child(c.id),
|
||||
"avatar_options": coordinator.avatar_options_for_child(c),
|
||||
"challenges": coordinator.challenge_progress_for_child(c.id),
|
||||
}
|
||||
)
|
||||
return summary
|
||||
|
||||
|
||||
@@ -223,61 +222,61 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d
|
||||
"time_category": c.time_category,
|
||||
"assigned_to": assigned_to,
|
||||
"depends_on": depends_on,
|
||||
"schedule_mode": getattr(c, 'schedule_mode', 'specific_days'),
|
||||
"enabled": getattr(c, 'enabled', True),
|
||||
"assignment_mode": getattr(c, 'assignment_mode', 'everyone'),
|
||||
"schedule_mode": getattr(c, "schedule_mode", "specific_days"),
|
||||
"enabled": getattr(c, "enabled", True),
|
||||
"assignment_mode": getattr(c, "assignment_mode", "everyone"),
|
||||
}
|
||||
# Difficulty tier + the points it actually awards. Emitted only when
|
||||
# non-default (medium / ×1.0) so simple chores stay compact.
|
||||
difficulty = getattr(c, 'difficulty', 'medium') or 'medium'
|
||||
if difficulty != 'medium':
|
||||
difficulty = getattr(c, "difficulty", "medium") or "medium"
|
||||
if difficulty != "medium":
|
||||
record["difficulty"] = difficulty
|
||||
effective_points = coordinator.effective_chore_points(c)
|
||||
if effective_points != c.points:
|
||||
record["effective_points"] = effective_points
|
||||
# Optional fields — emit only when non-default to save bytes.
|
||||
description = getattr(c, 'description', '') or ''
|
||||
description = getattr(c, "description", "") or ""
|
||||
if description:
|
||||
record["description"] = description
|
||||
daily_limit = getattr(c, 'daily_limit', 1)
|
||||
daily_limit = getattr(c, "daily_limit", 1)
|
||||
if daily_limit != 1:
|
||||
record["daily_limit"] = daily_limit
|
||||
claim_allowance_minutes = getattr(c, 'claim_allowance_minutes', 0) or 0
|
||||
claim_allowance_minutes = getattr(c, "claim_allowance_minutes", 0) or 0
|
||||
if claim_allowance_minutes:
|
||||
record["claim_allowance_minutes"] = claim_allowance_minutes
|
||||
due_days = getattr(c, 'due_days', []) or []
|
||||
due_days = getattr(c, "due_days", []) or []
|
||||
if due_days:
|
||||
record["due_days"] = due_days
|
||||
requires_approval = getattr(c, 'requires_approval', True)
|
||||
requires_approval = getattr(c, "requires_approval", True)
|
||||
if not requires_approval:
|
||||
record["requires_approval"] = False
|
||||
# Mandatory chores (#532): emit only when set so the child card can
|
||||
# show the mandatory styling/badge. penalty rides along when non-zero.
|
||||
if getattr(c, 'mandatory', False):
|
||||
if getattr(c, "mandatory", False):
|
||||
record["mandatory"] = True
|
||||
penalty = getattr(c, 'mandatory_penalty_points', 0) or 0
|
||||
penalty = getattr(c, "mandatory_penalty_points", 0) or 0
|
||||
if penalty:
|
||||
record["mandatory_penalty_points"] = penalty
|
||||
if getattr(c, 'require_photo', False):
|
||||
if getattr(c, "require_photo", False):
|
||||
record["require_photo"] = True
|
||||
recurrence = getattr(c, 'recurrence', 'weekly')
|
||||
if recurrence != 'weekly':
|
||||
recurrence = getattr(c, "recurrence", "weekly")
|
||||
if recurrence != "weekly":
|
||||
record["recurrence"] = recurrence
|
||||
recurrence_day = getattr(c, 'recurrence_day', '')
|
||||
recurrence_day = getattr(c, "recurrence_day", "")
|
||||
if recurrence_day:
|
||||
record["recurrence_day"] = recurrence_day
|
||||
recurrence_start = getattr(c, 'recurrence_start', '')
|
||||
recurrence_start = getattr(c, "recurrence_start", "")
|
||||
if recurrence_start:
|
||||
record["recurrence_start"] = recurrence_start
|
||||
visibility_entity = getattr(c, 'visibility_entity', '')
|
||||
visibility_entity = getattr(c, "visibility_entity", "")
|
||||
if visibility_entity:
|
||||
record["visibility_entity"] = visibility_entity
|
||||
record["visibility_operator"] = getattr(c, 'visibility_operator', 'equals')
|
||||
record["visibility_state"] = getattr(c, 'visibility_state', 'on')
|
||||
weather_entity = getattr(c, 'weather_entity', '')
|
||||
record["visibility_operator"] = getattr(c, "visibility_operator", "equals")
|
||||
record["visibility_state"] = getattr(c, "visibility_state", "on")
|
||||
weather_entity = getattr(c, "weather_entity", "")
|
||||
if weather_entity:
|
||||
record["weather_entity"] = weather_entity
|
||||
record["weather_block_conditions"] = list(getattr(c, 'weather_block_conditions', []) or [])
|
||||
record["weather_block_conditions"] = list(getattr(c, "weather_block_conditions", []) or [])
|
||||
for limit in ("weather_temp_min", "weather_temp_max", "weather_wind_max"):
|
||||
value = getattr(c, limit, None)
|
||||
if value is not None:
|
||||
@@ -287,44 +286,41 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d
|
||||
reason = coordinator.weather_block_reason(c)
|
||||
if reason:
|
||||
record["weather_blocked"] = reason
|
||||
deadline_at = getattr(c, 'deadline_at', '')
|
||||
deadline_at = getattr(c, "deadline_at", "")
|
||||
if deadline_at:
|
||||
record["deadline_at"] = deadline_at
|
||||
speed_bonus = getattr(c, 'speed_bonus_points', 0)
|
||||
speed_bonus = getattr(c, "speed_bonus_points", 0)
|
||||
if speed_bonus:
|
||||
record["speed_bonus_points"] = speed_bonus
|
||||
disabled_for = getattr(c, 'disabled_for', [])
|
||||
disabled_for = getattr(c, "disabled_for", [])
|
||||
if disabled_for:
|
||||
record["disabled_for"] = disabled_for
|
||||
created_date = getattr(c, 'created_date', '')
|
||||
created_date = getattr(c, "created_date", "")
|
||||
if created_date:
|
||||
record["created_date"] = created_date
|
||||
assignment_current_child_id = getattr(c, 'assignment_current_child_id', '')
|
||||
assignment_current_child_id = getattr(c, "assignment_current_child_id", "")
|
||||
if assignment_current_child_id:
|
||||
record["assignment_current_child_id"] = assignment_current_child_id
|
||||
icon = getattr(c, 'icon', '')
|
||||
icon = getattr(c, "icon", "")
|
||||
if icon:
|
||||
record["icon"] = icon
|
||||
# Signed so the card's <img> loads; emitted only when set, matching
|
||||
# `icon` above, to keep records under the 16KB recorder limit.
|
||||
image_url = getattr(c, 'image_url', '')
|
||||
image_url = getattr(c, "image_url", "")
|
||||
if image_url:
|
||||
record["image_url"] = images.sign_image_url(common["hass"], image_url)
|
||||
completion_sound = getattr(c, 'completion_sound', 'coin')
|
||||
if completion_sound and completion_sound != 'coin':
|
||||
completion_sound = getattr(c, "completion_sound", "coin")
|
||||
if completion_sound and completion_sound != "coin":
|
||||
record["completion_sound"] = completion_sound
|
||||
task_type = getattr(c, 'task_type', 'standard')
|
||||
task_type = getattr(c, "task_type", "standard")
|
||||
if task_type == "timed":
|
||||
record["task_type"] = "timed"
|
||||
record["timed_rate_points"] = getattr(c, 'timed_rate_points', 10)
|
||||
record["timed_rate_minutes"] = getattr(c, 'timed_rate_minutes', 5)
|
||||
record["timed_max_daily_minutes"] = getattr(c, 'timed_max_daily_minutes', 0)
|
||||
bonus_subtasks = getattr(c, 'bonus_subtasks', [])
|
||||
record["timed_rate_points"] = getattr(c, "timed_rate_points", 10)
|
||||
record["timed_rate_minutes"] = getattr(c, "timed_rate_minutes", 5)
|
||||
record["timed_max_daily_minutes"] = getattr(c, "timed_max_daily_minutes", 0)
|
||||
bonus_subtasks = getattr(c, "bonus_subtasks", [])
|
||||
if bonus_subtasks:
|
||||
record["bonus_subtasks"] = [
|
||||
{"id": b.id, "name": b.name, "points": b.points}
|
||||
for b in bonus_subtasks
|
||||
]
|
||||
record["bonus_subtasks"] = [{"id": b.id, "name": b.name, "points": b.points} for b in bonus_subtasks]
|
||||
chores_list.append(record)
|
||||
return chores_list
|
||||
|
||||
@@ -356,9 +352,9 @@ def _build_todays_completions(common: dict) -> list[dict]:
|
||||
out = []
|
||||
for comp in common["all_completions"]:
|
||||
comp_dt = comp.completed_at
|
||||
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 comp_dt
|
||||
comp_date = comp_dt.date() if hasattr(comp_dt, "date") else comp_dt
|
||||
if comp_date != today:
|
||||
continue
|
||||
matched_chore = chore_lookup.get(comp.chore_id)
|
||||
@@ -379,11 +375,15 @@ def _build_todays_completions(common: dict) -> list[dict]:
|
||||
"completion_id": comp.id,
|
||||
"chore_id": comp.chore_id,
|
||||
"child_id": comp.child_id,
|
||||
"child_name": "Parent" if comp.child_id == "__parent__" else (child_lookup[comp.child_id].name if comp.child_id in child_lookup else ""),
|
||||
"child_name": "Parent"
|
||||
if comp.child_id == "__parent__"
|
||||
else (child_lookup[comp.child_id].name if comp.child_id in child_lookup else ""),
|
||||
"chore_name": display_name,
|
||||
"points": display_points,
|
||||
"approved": comp.approved,
|
||||
"completed_at": comp.completed_at.isoformat() if hasattr(comp.completed_at, 'isoformat') else str(comp.completed_at),
|
||||
"completed_at": comp.completed_at.isoformat()
|
||||
if hasattr(comp.completed_at, "isoformat")
|
||||
else str(comp.completed_at),
|
||||
"bonus_subtask_id": bonus_subtask_id,
|
||||
}
|
||||
if timed_secs > 0:
|
||||
@@ -402,7 +402,7 @@ def _build_active_timed_sessions(coordinator: TaskMateCoordinator) -> list[dict]
|
||||
sessions = coordinator.data.get("timed_sessions", [])
|
||||
out = []
|
||||
for s in sessions:
|
||||
if hasattr(s, 'state'):
|
||||
if hasattr(s, "state"):
|
||||
state = s.state
|
||||
if state not in ("running", "paused"):
|
||||
continue
|
||||
@@ -412,13 +412,15 @@ def _build_active_timed_sessions(coordinator: TaskMateCoordinator) -> list[dict]
|
||||
last_seg = segments[-1]
|
||||
if isinstance(last_seg, dict) and last_seg.get("end") is None:
|
||||
current_segment_start = last_seg.get("start", "")
|
||||
out.append({
|
||||
"chore_id": s.chore_id,
|
||||
"child_id": s.child_id,
|
||||
"state": state,
|
||||
"total_seconds_today": s.total_seconds_today,
|
||||
"current_segment_start": current_segment_start,
|
||||
})
|
||||
out.append(
|
||||
{
|
||||
"chore_id": s.chore_id,
|
||||
"child_id": s.child_id,
|
||||
"state": state,
|
||||
"total_seconds_today": s.total_seconds_today,
|
||||
"current_segment_start": current_segment_start,
|
||||
}
|
||||
)
|
||||
else:
|
||||
state = s.get("state", "stopped") if isinstance(s, dict) else "stopped"
|
||||
if state not in ("running", "paused"):
|
||||
@@ -429,13 +431,15 @@ def _build_active_timed_sessions(coordinator: TaskMateCoordinator) -> list[dict]
|
||||
last_seg = segments[-1]
|
||||
if isinstance(last_seg, dict) and last_seg.get("end") is None:
|
||||
current_segment_start = last_seg.get("start", "")
|
||||
out.append({
|
||||
"chore_id": s.get("chore_id", "") if isinstance(s, dict) else "",
|
||||
"child_id": s.get("child_id", "") if isinstance(s, dict) else "",
|
||||
"state": state,
|
||||
"total_seconds_today": s.get("total_seconds_today", 0) if isinstance(s, dict) else 0,
|
||||
"current_segment_start": current_segment_start,
|
||||
})
|
||||
out.append(
|
||||
{
|
||||
"chore_id": s.get("chore_id", "") if isinstance(s, dict) else "",
|
||||
"child_id": s.get("child_id", "") if isinstance(s, dict) else "",
|
||||
"state": state,
|
||||
"total_seconds_today": s.get("total_seconds_today", 0) if isinstance(s, dict) else 0,
|
||||
"current_segment_start": current_segment_start,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@@ -448,20 +452,12 @@ def _build_rewards_list(common: dict) -> list[dict]:
|
||||
today = dt_util.now().date()
|
||||
out = []
|
||||
for r in rewards:
|
||||
assigned = (
|
||||
r.assigned_to
|
||||
if isinstance(r.assigned_to, list) and r.assigned_to
|
||||
else [c.id for c in children]
|
||||
)
|
||||
assigned = r.assigned_to if isinstance(r.assigned_to, list) and r.assigned_to else [c.id for c in children]
|
||||
calculated_costs = {child_id: r.cost for child_id in assigned}
|
||||
reward_pool_allocations = {
|
||||
cid: pool_by_child_reward.get(cid, {}).get(r.id, 0) for cid in assigned
|
||||
}
|
||||
jackpot_pool_total = (
|
||||
pool_total_by_reward.get(r.id, 0) if getattr(r, 'is_jackpot', False) else None
|
||||
)
|
||||
quantity = getattr(r, 'quantity', None)
|
||||
expires_at = getattr(r, 'expires_at', None)
|
||||
reward_pool_allocations = {cid: pool_by_child_reward.get(cid, {}).get(r.id, 0) for cid in assigned}
|
||||
jackpot_pool_total = pool_total_by_reward.get(r.id, 0) if getattr(r, "is_jackpot", False) else None
|
||||
quantity = getattr(r, "quantity", None)
|
||||
expires_at = getattr(r, "expires_at", None)
|
||||
is_sold_out = quantity is not None and quantity <= 0
|
||||
is_expired = False
|
||||
days_until_expiry: int | None = None
|
||||
@@ -472,25 +468,27 @@ def _build_rewards_list(common: dict) -> list[dict]:
|
||||
days_until_expiry = (deadline - today).days
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
out.append({
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"cost": r.cost,
|
||||
"description": getattr(r, 'description', ''),
|
||||
"icon": r.icon,
|
||||
"assigned_to": r.assigned_to if isinstance(r.assigned_to, list) else [],
|
||||
"is_jackpot": getattr(r, 'is_jackpot', False),
|
||||
"pool_enabled": getattr(r, 'pool_enabled', False),
|
||||
"calculated_costs": calculated_costs,
|
||||
"pool_allocations": reward_pool_allocations,
|
||||
"jackpot_pool_total": jackpot_pool_total,
|
||||
"quantity": quantity,
|
||||
"expires_at": expires_at,
|
||||
"is_sold_out": is_sold_out,
|
||||
"is_expired": is_expired,
|
||||
"is_available": not (is_sold_out or is_expired),
|
||||
"days_until_expiry": days_until_expiry,
|
||||
})
|
||||
out.append(
|
||||
{
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"cost": r.cost,
|
||||
"description": getattr(r, "description", ""),
|
||||
"icon": r.icon,
|
||||
"assigned_to": r.assigned_to if isinstance(r.assigned_to, list) else [],
|
||||
"is_jackpot": getattr(r, "is_jackpot", False),
|
||||
"pool_enabled": getattr(r, "pool_enabled", False),
|
||||
"calculated_costs": calculated_costs,
|
||||
"pool_allocations": reward_pool_allocations,
|
||||
"jackpot_pool_total": jackpot_pool_total,
|
||||
"quantity": quantity,
|
||||
"expires_at": expires_at,
|
||||
"is_sold_out": is_sold_out,
|
||||
"is_expired": is_expired,
|
||||
"is_available": not (is_sold_out or is_expired),
|
||||
"days_until_expiry": days_until_expiry,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@@ -504,17 +502,19 @@ def _build_pending_reward_claims(common: dict) -> list[dict]:
|
||||
child = child_lookup.get(rc.child_id)
|
||||
if not reward or not child:
|
||||
continue
|
||||
out.append({
|
||||
"claim_id": rc.id,
|
||||
"reward_id": rc.reward_id,
|
||||
"child_id": rc.child_id,
|
||||
"child_name": child.name,
|
||||
"child_avatar": getattr(child, 'avatar', 'mdi:account-circle') or 'mdi:account-circle',
|
||||
"reward_name": reward.name,
|
||||
"reward_icon": reward.icon or 'mdi:gift',
|
||||
"cost": reward.cost,
|
||||
"claimed_at": rc.claimed_at.isoformat() if hasattr(rc.claimed_at, 'isoformat') else str(rc.claimed_at),
|
||||
})
|
||||
out.append(
|
||||
{
|
||||
"claim_id": rc.id,
|
||||
"reward_id": rc.reward_id,
|
||||
"child_id": rc.child_id,
|
||||
"child_name": child.name,
|
||||
"child_avatar": getattr(child, "avatar", "mdi:account-circle") or "mdi:account-circle",
|
||||
"reward_name": reward.name,
|
||||
"reward_icon": reward.icon or "mdi:gift",
|
||||
"cost": reward.cost,
|
||||
"claimed_at": rc.claimed_at.isoformat() if hasattr(rc.claimed_at, "isoformat") else str(rc.claimed_at),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@@ -536,16 +536,22 @@ def _build_recent_completions(common: dict, limit: int = 35) -> list[dict]:
|
||||
rate_seconds = matched_chore.timed_rate_minutes * 60
|
||||
if rate_seconds > 0:
|
||||
display_points = (timed_secs // rate_seconds) * matched_chore.timed_rate_points
|
||||
out.append({
|
||||
"completion_id": comp.id,
|
||||
"chore_id": comp.chore_id,
|
||||
"child_id": comp.child_id,
|
||||
"child_name": "Parent" if comp.child_id == "__parent__" else (child_lookup[comp.child_id].name if comp.child_id in child_lookup else ""),
|
||||
"chore_name": matched_chore.name if matched_chore else "",
|
||||
"points": display_points,
|
||||
"approved": comp.approved,
|
||||
"completed_at": comp.completed_at.isoformat() if hasattr(comp.completed_at, 'isoformat') else str(comp.completed_at),
|
||||
})
|
||||
out.append(
|
||||
{
|
||||
"completion_id": comp.id,
|
||||
"chore_id": comp.chore_id,
|
||||
"child_id": comp.child_id,
|
||||
"child_name": "Parent"
|
||||
if comp.child_id == "__parent__"
|
||||
else (child_lookup[comp.child_id].name if comp.child_id in child_lookup else ""),
|
||||
"chore_name": matched_chore.name if matched_chore else "",
|
||||
"points": display_points,
|
||||
"approved": comp.approved,
|
||||
"completed_at": comp.completed_at.isoformat()
|
||||
if hasattr(comp.completed_at, "isoformat")
|
||||
else str(comp.completed_at),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@@ -558,21 +564,23 @@ def _build_photo_gallery(common: dict, limit: int = 40) -> list[dict]:
|
||||
"""
|
||||
child_lookup = common["child_lookup"]
|
||||
chore_lookup = common["chore_lookup"]
|
||||
with_photos = [
|
||||
c for c in common["all_completions"] if getattr(c, "photo_url", "")
|
||||
]
|
||||
with_photos = [c for c in common["all_completions"] if getattr(c, "photo_url", "")]
|
||||
recent = sorted(with_photos, key=lambda c: c.completed_at, reverse=True)[:limit]
|
||||
out = []
|
||||
for comp in recent:
|
||||
chore = chore_lookup.get(comp.chore_id)
|
||||
out.append({
|
||||
"completion_id": comp.id,
|
||||
"child_name": child_lookup[comp.child_id].name if comp.child_id in child_lookup else "",
|
||||
"chore_name": chore.name if chore else "",
|
||||
"approved": comp.approved,
|
||||
"completed_at": comp.completed_at.isoformat() if hasattr(comp.completed_at, "isoformat") else str(comp.completed_at),
|
||||
"photo_url": comp.photo_url,
|
||||
})
|
||||
out.append(
|
||||
{
|
||||
"completion_id": comp.id,
|
||||
"child_name": child_lookup[comp.child_id].name if comp.child_id in child_lookup else "",
|
||||
"chore_name": chore.name if chore else "",
|
||||
"approved": comp.approved,
|
||||
"completed_at": comp.completed_at.isoformat()
|
||||
if hasattr(comp.completed_at, "isoformat")
|
||||
else str(comp.completed_at),
|
||||
"photo_url": comp.photo_url,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@@ -593,15 +601,17 @@ def _build_recent_transactions(common: dict, limit: int = 20) -> list[dict]:
|
||||
child = child_lookup.get(t.child_id)
|
||||
if not child:
|
||||
continue
|
||||
events.append({
|
||||
"transaction_id": t.id,
|
||||
"type": "points_added" if t.points > 0 else "points_removed",
|
||||
"child_id": t.child_id,
|
||||
"child_name": child.name,
|
||||
"points": t.points,
|
||||
"reason": t.reason or "",
|
||||
"created_at": t.created_at.isoformat() if hasattr(t.created_at, 'isoformat') else str(t.created_at),
|
||||
})
|
||||
events.append(
|
||||
{
|
||||
"transaction_id": t.id,
|
||||
"type": "points_added" if t.points > 0 else "points_removed",
|
||||
"child_id": t.child_id,
|
||||
"child_name": child.name,
|
||||
"points": t.points,
|
||||
"reason": t.reason or "",
|
||||
"created_at": t.created_at.isoformat() if hasattr(t.created_at, "isoformat") else str(t.created_at),
|
||||
}
|
||||
)
|
||||
|
||||
for rc in all_reward_claims:
|
||||
child = child_lookup.get(rc.child_id)
|
||||
@@ -610,43 +620,51 @@ def _build_recent_transactions(common: dict, limit: int = 20) -> list[dict]:
|
||||
continue
|
||||
event_type = "reward_approved" if rc.approved else "reward_claimed"
|
||||
timestamp = rc.approved_at if rc.approved and rc.approved_at else rc.claimed_at
|
||||
events.append({
|
||||
"transaction_id": rc.id,
|
||||
"type": event_type,
|
||||
"child_id": rc.child_id,
|
||||
"child_name": child.name,
|
||||
"reward_id": rc.reward_id,
|
||||
"reward_name": reward.name,
|
||||
"reward_icon": reward.icon or "mdi:gift",
|
||||
"points": -reward.cost,
|
||||
"approved": rc.approved,
|
||||
"created_at": timestamp.isoformat() if hasattr(timestamp, 'isoformat') else str(timestamp),
|
||||
})
|
||||
events.append(
|
||||
{
|
||||
"transaction_id": rc.id,
|
||||
"type": event_type,
|
||||
"child_id": rc.child_id,
|
||||
"child_name": child.name,
|
||||
"reward_id": rc.reward_id,
|
||||
"reward_name": reward.name,
|
||||
"reward_icon": reward.icon or "mdi:gift",
|
||||
"points": -reward.cost,
|
||||
"approved": rc.approved,
|
||||
"created_at": timestamp.isoformat() if hasattr(timestamp, "isoformat") else str(timestamp),
|
||||
}
|
||||
)
|
||||
|
||||
events.sort(key=lambda e: e["created_at"], reverse=True)
|
||||
return events[:limit]
|
||||
|
||||
|
||||
def _build_penalties_list(common: dict) -> list[dict]:
|
||||
return [{
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"points": p.points,
|
||||
"description": p.description,
|
||||
"icon": p.icon,
|
||||
"assigned_to": p.assigned_to or [],
|
||||
} for p in common["data"].get("penalties", [])]
|
||||
return [
|
||||
{
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"points": p.points,
|
||||
"description": p.description,
|
||||
"icon": p.icon,
|
||||
"assigned_to": p.assigned_to or [],
|
||||
}
|
||||
for p in common["data"].get("penalties", [])
|
||||
]
|
||||
|
||||
|
||||
def _build_bonuses_list(common: dict) -> list[dict]:
|
||||
return [{
|
||||
"id": b.id,
|
||||
"name": b.name,
|
||||
"points": b.points,
|
||||
"description": b.description,
|
||||
"icon": b.icon,
|
||||
"assigned_to": b.assigned_to or [],
|
||||
} for b in common["data"].get("bonuses", [])]
|
||||
return [
|
||||
{
|
||||
"id": b.id,
|
||||
"name": b.name,
|
||||
"points": b.points,
|
||||
"description": b.description,
|
||||
"icon": b.icon,
|
||||
"assigned_to": b.assigned_to or [],
|
||||
}
|
||||
for b in common["data"].get("bonuses", [])
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -795,8 +813,10 @@ class TaskMateOverallStatsSensor(_CachedAttrsSensor):
|
||||
|
||||
# Legacy shape kept for cards that still read the four fixed keys.
|
||||
legacy_defaults = {
|
||||
"morning": ("06:00", "12:00"), "afternoon": ("12:00", "17:00"),
|
||||
"evening": ("17:00", "21:00"), "night": ("21:00", "23:59"),
|
||||
"morning": ("06:00", "12:00"),
|
||||
"afternoon": ("12:00", "17:00"),
|
||||
"evening": ("17:00", "21:00"),
|
||||
"night": ("21:00", "23:59"),
|
||||
}
|
||||
time_boundaries = {}
|
||||
for cat, (def_start, def_end) in legacy_defaults.items():
|
||||
@@ -813,7 +833,8 @@ class TaskMateOverallStatsSensor(_CachedAttrsSensor):
|
||||
"perfect_week_enabled": settings.get("perfect_week_enabled", "true") == "true",
|
||||
"perfect_week_bonus": _safe_int(settings.get("perfect_week_bonus"), 50),
|
||||
"streak_requires_all_chores": settings.get("streak_requires_all_chores", "false") in (True, "true"),
|
||||
"perfect_week_requires_all_chores": settings.get("perfect_week_requires_all_chores", "false") in (True, "true"),
|
||||
"perfect_week_requires_all_chores": settings.get("perfect_week_requires_all_chores", "false")
|
||||
in (True, "true"),
|
||||
"total_children": len(children),
|
||||
"total_chores": len(chores),
|
||||
"total_rewards": len(rewards),
|
||||
@@ -1117,6 +1138,7 @@ class ChildStatsSensor(TaskMateBaseSensor):
|
||||
# drop it once any pool member has completed it today (so a parent
|
||||
# crediting the off-rotation child clears the chore for everyone).
|
||||
chores = self.coordinator.data.get("chores", [])
|
||||
|
||||
def _included(c):
|
||||
if not (child.id in c.assigned_to or not c.assigned_to):
|
||||
return False
|
||||
@@ -1128,6 +1150,7 @@ class ChildStatsSensor(TaskMateBaseSensor):
|
||||
if self.coordinator._is_rotation_done_today(c):
|
||||
return False
|
||||
return True
|
||||
|
||||
assigned_chores = [c for c in chores if _included(c)]
|
||||
|
||||
return {
|
||||
@@ -1141,7 +1164,10 @@ class ChildStatsSensor(TaskMateBaseSensor):
|
||||
"best_streak": child.best_streak,
|
||||
"career_score": child.career_score,
|
||||
"total_penalties_received": child.total_penalties_received,
|
||||
"assigned_chores": [{"id": c.id, "name": c.name, "points": c.points, "time_category": c.time_category} for c in assigned_chores],
|
||||
"assigned_chores": [
|
||||
{"id": c.id, "name": c.name, "points": c.points, "time_category": c.time_category}
|
||||
for c in assigned_chores
|
||||
],
|
||||
"chore_order": child.chore_order,
|
||||
}
|
||||
|
||||
@@ -1166,9 +1192,7 @@ class ChildBadgesSensor(TaskMateBaseSensor):
|
||||
@property
|
||||
def native_value(self) -> int:
|
||||
"""Number of badges earned by this child."""
|
||||
return len(
|
||||
self.coordinator.storage.get_awarded_badges_for_child(self.child_id)
|
||||
)
|
||||
return len(self.coordinator.storage.get_awarded_badges_for_child(self.child_id))
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict:
|
||||
@@ -1180,10 +1204,7 @@ class ChildBadgesSensor(TaskMateBaseSensor):
|
||||
return {"earned": [], "available": [], "total_badges": 0}
|
||||
|
||||
all_badges = [b for b in storage.get_badges() if b.enabled]
|
||||
applicable = [
|
||||
b for b in all_badges
|
||||
if not b.assigned_to or self.child_id in b.assigned_to
|
||||
]
|
||||
applicable = [b for b in all_badges if not b.assigned_to or self.child_id in b.assigned_to]
|
||||
|
||||
awarded_records = storage.get_awarded_badges_for_child(self.child_id)
|
||||
record_by_id = {a.badge_id: a for a in awarded_records}
|
||||
@@ -1193,35 +1214,46 @@ class ChildBadgesSensor(TaskMateBaseSensor):
|
||||
for b in applicable:
|
||||
if b.id in record_by_id:
|
||||
rec = record_by_id[b.id]
|
||||
earned.append({
|
||||
"badge_id": b.id,
|
||||
"name": b.name,
|
||||
"icon": b.icon,
|
||||
"tier": b.tier,
|
||||
"earned_at": rec.earned_at.isoformat() if rec.earned_at else None,
|
||||
"manually_awarded": rec.manually_awarded,
|
||||
"silent": rec.silent,
|
||||
})
|
||||
earned.append(
|
||||
{
|
||||
"badge_id": b.id,
|
||||
"name": b.name,
|
||||
"icon": b.icon,
|
||||
"tier": b.tier,
|
||||
"earned_at": rec.earned_at.isoformat() if rec.earned_at else None,
|
||||
"manually_awarded": rec.manually_awarded,
|
||||
"silent": rec.silent,
|
||||
}
|
||||
)
|
||||
else:
|
||||
if not b.criteria:
|
||||
# The criterion that actually gates the award: for an AND badge
|
||||
# that is the weakest one, for an OR badge the strongest. Using
|
||||
# min() for both understated OR badges (and could report <100%
|
||||
# for one already earned) — see coord_badges.BadgeEvaluator.
|
||||
combinator = (getattr(b, "combinator", "AND") or "AND").upper()
|
||||
scored = []
|
||||
for c in b.criteria:
|
||||
cur = resolve_metric(c.metric, child, storage)
|
||||
pct = min(100, int(100 * cur / max(c.value, 1)))
|
||||
scored.append((pct, c.metric, cur, c.value))
|
||||
if not scored:
|
||||
progress_pct = 0
|
||||
closest = None
|
||||
else:
|
||||
pcts = []
|
||||
for c in b.criteria:
|
||||
cur = resolve_metric(c.metric, child, storage)
|
||||
target = max(c.value, 1)
|
||||
pcts.append(min(100, int(100 * cur / target)))
|
||||
progress_pct = min(pcts) if pcts else 0
|
||||
available.append({
|
||||
"badge_id": b.id,
|
||||
"name": b.name,
|
||||
"icon": b.icon,
|
||||
"tier": b.tier,
|
||||
"progress_pct": progress_pct,
|
||||
"criteria_summary": ", ".join(
|
||||
f"{c.metric} >= {c.value}" for c in b.criteria
|
||||
),
|
||||
})
|
||||
pick = max(scored) if combinator == "OR" else min(scored)
|
||||
progress_pct = pick[0]
|
||||
closest = {"metric": pick[1], "current": pick[2], "target": pick[3]}
|
||||
available.append(
|
||||
{
|
||||
"badge_id": b.id,
|
||||
"name": b.name,
|
||||
"icon": b.icon,
|
||||
"tier": b.tier,
|
||||
"progress_pct": progress_pct,
|
||||
"closest_criterion": closest,
|
||||
"criteria_summary": ", ".join(f"{c.metric} >= {c.value}" for c in b.criteria),
|
||||
}
|
||||
)
|
||||
|
||||
earned.sort(key=lambda e: e.get("earned_at") or "", reverse=True)
|
||||
|
||||
@@ -1274,9 +1306,7 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
|
||||
# list render them identically.
|
||||
bonus_subtask_id = getattr(comp, "bonus_subtask_id", "") or ""
|
||||
if bonus_subtask_id:
|
||||
subtask = next(
|
||||
(b for b in chore.bonus_subtasks if b.id == bonus_subtask_id), None
|
||||
)
|
||||
subtask = next((b for b in chore.bonus_subtasks if b.id == bonus_subtask_id), None)
|
||||
chore_name = f"{chore.name} › {subtask.name}" if subtask else chore.name
|
||||
pts = subtask.points if subtask else 0
|
||||
else:
|
||||
@@ -1303,9 +1333,7 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
|
||||
detail["timed_duration_seconds"] = timed_secs
|
||||
photo = getattr(comp, "photo_url", "") or ""
|
||||
if photo:
|
||||
detail["photo_url"] = photos.sign_photo_url(
|
||||
self.coordinator.hass, photo
|
||||
)
|
||||
detail["photo_url"] = photos.sign_photo_url(self.coordinator.hass, photo)
|
||||
completion_details.append(detail)
|
||||
|
||||
reward_details = []
|
||||
@@ -1313,16 +1341,18 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
|
||||
child = self.coordinator.get_child(claim.child_id)
|
||||
reward = self.coordinator.get_reward(claim.reward_id)
|
||||
if child and reward:
|
||||
reward_details.append({
|
||||
"claim_id": claim.id,
|
||||
"type": "reward",
|
||||
"child_name": child.name,
|
||||
"child_id": child.id,
|
||||
"reward_name": reward.name,
|
||||
"reward_id": reward.id,
|
||||
"cost": reward.cost,
|
||||
"claimed_at": claim.claimed_at.isoformat(),
|
||||
})
|
||||
reward_details.append(
|
||||
{
|
||||
"claim_id": claim.id,
|
||||
"type": "reward",
|
||||
"child_name": child.name,
|
||||
"child_id": child.id,
|
||||
"reward_name": reward.name,
|
||||
"reward_id": reward.id,
|
||||
"cost": reward.cost,
|
||||
"claimed_at": claim.claimed_at.isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
mandatory_misses = self.coordinator.mandatory_misses_state()
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Storage management for TaskMate integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -120,6 +121,15 @@ class TaskMateStorage:
|
||||
if "scheduled_changes" not in self._data:
|
||||
self._data["scheduled_changes"] = []
|
||||
|
||||
# Drop settled swap requests (#783) and ones pointing at a chore or child
|
||||
# that no longer exists (#785). Approval used to only flip a status flag,
|
||||
# and deletes did not cascade, so existing installs carry both kinds —
|
||||
# the orphans surface in the parent's approval queue as an unclearable
|
||||
# "? wants to swap ?" row. Runs on every load rather than behind a
|
||||
# one-shot flag: it is a cheap list filter, and it also sweeps up records
|
||||
# left by a downgrade to a version without the cascades.
|
||||
self._drop_dead_swap_requests()
|
||||
|
||||
# Notifications overhaul (v3.9.0)
|
||||
if "parent_recipients" not in self._data:
|
||||
self._data["parent_recipients"] = []
|
||||
@@ -182,8 +192,8 @@ class TaskMateStorage:
|
||||
self._data["_pool_semantics_version"] = 2
|
||||
if adjusted:
|
||||
_LOGGER.info(
|
||||
"TaskMate: migrated %d pool allocation(s) to beta2 semantics "
|
||||
"(points now deducted at allocation time)", adjusted
|
||||
"TaskMate: migrated %d pool allocation(s) to beta2 semantics (points now deducted at allocation time)",
|
||||
adjusted,
|
||||
)
|
||||
await self.async_save()
|
||||
|
||||
@@ -232,15 +242,13 @@ class TaskMateStorage:
|
||||
"Migrating chore '%s' assigned_to: '%s' -> '%s' (name to ID)",
|
||||
chore.get("name", "unknown"),
|
||||
assignment,
|
||||
name_to_id[assignment]
|
||||
name_to_id[assignment],
|
||||
)
|
||||
else:
|
||||
# Unknown value, keep it but log a warning
|
||||
new_assigned_to.append(assignment)
|
||||
_LOGGER.warning(
|
||||
"Chore '%s' has unknown assigned_to value: '%s'",
|
||||
chore.get("name", "unknown"),
|
||||
assignment
|
||||
"Chore '%s' has unknown assigned_to value: '%s'", chore.get("name", "unknown"), assignment
|
||||
)
|
||||
|
||||
if chore_modified:
|
||||
@@ -269,10 +277,7 @@ class TaskMateStorage:
|
||||
|
||||
self._data["_career_score_initialized"] = True
|
||||
if children:
|
||||
_LOGGER.info(
|
||||
"TaskMate: initialized career_score for %d child(ren) "
|
||||
"from total_points_earned", len(children)
|
||||
)
|
||||
_LOGGER.info("TaskMate: initialized career_score for %d child(ren) from total_points_earned", len(children))
|
||||
await self.async_save()
|
||||
|
||||
async def async_save(self) -> None:
|
||||
@@ -336,9 +341,7 @@ class TaskMateStorage:
|
||||
|
||||
def remove_child(self, child_id: str) -> None:
|
||||
"""Remove a child and cascade-delete their awarded badges."""
|
||||
self._data["children"] = [
|
||||
c for c in self._data.get("children", []) if c.get("id") != child_id
|
||||
]
|
||||
self._data["children"] = [c for c in self._data.get("children", []) if c.get("id") != child_id]
|
||||
self.remove_awards_for_child(child_id)
|
||||
|
||||
# Chores management
|
||||
@@ -370,9 +373,7 @@ class TaskMateStorage:
|
||||
|
||||
def remove_chore(self, chore_id: str) -> None:
|
||||
"""Remove a chore."""
|
||||
self._data["chores"] = [
|
||||
c for c in self._data.get("chores", []) if c.get("id") != chore_id
|
||||
]
|
||||
self._data["chores"] = [c for c in self._data.get("chores", []) if c.get("id") != chore_id]
|
||||
order = self._data.get("chore_display_order", [])
|
||||
if chore_id in order:
|
||||
order.remove(chore_id)
|
||||
@@ -414,9 +415,7 @@ class TaskMateStorage:
|
||||
|
||||
def remove_reward(self, reward_id: str) -> None:
|
||||
"""Remove a reward."""
|
||||
self._data["rewards"] = [
|
||||
r for r in self._data.get("rewards", []) if r.get("id") != reward_id
|
||||
]
|
||||
self._data["rewards"] = [r for r in self._data.get("rewards", []) if r.get("id") != reward_id]
|
||||
|
||||
# Completions management
|
||||
def get_completions(self) -> list[ChoreCompletion]:
|
||||
@@ -447,9 +446,7 @@ class TaskMateStorage:
|
||||
|
||||
def remove_completion(self, completion_id: str) -> None:
|
||||
"""Remove a completion record."""
|
||||
self._data["completions"] = [
|
||||
c for c in self._data.get("completions", []) if c.get("id") != completion_id
|
||||
]
|
||||
self._data["completions"] = [c for c in self._data.get("completions", []) if c.get("id") != completion_id]
|
||||
|
||||
# Mandatory-miss management (#532)
|
||||
def get_mandatory_misses(self) -> list[MandatoryMiss]:
|
||||
@@ -470,9 +467,7 @@ class TaskMateStorage:
|
||||
|
||||
def remove_mandatory_miss(self, miss_id: str) -> None:
|
||||
"""Remove a mandatory-miss item by id."""
|
||||
self._data["mandatory_misses"] = [
|
||||
m for m in self._data.get("mandatory_misses", []) if m.get("id") != miss_id
|
||||
]
|
||||
self._data["mandatory_misses"] = [m for m in self._data.get("mandatory_misses", []) if m.get("id") != miss_id]
|
||||
|
||||
def replace_mandatory_misses(self, misses: list[MandatoryMiss]) -> None:
|
||||
"""Replace the whole mandatory-miss collection."""
|
||||
@@ -507,9 +502,7 @@ class TaskMateStorage:
|
||||
|
||||
def remove_reward_claim(self, claim_id: str) -> None:
|
||||
"""Remove a reward claim."""
|
||||
self._data["reward_claims"] = [
|
||||
c for c in self._data.get("reward_claims", []) if c.get("id") != claim_id
|
||||
]
|
||||
self._data["reward_claims"] = [c for c in self._data.get("reward_claims", []) if c.get("id") != claim_id]
|
||||
|
||||
# Penalties management
|
||||
def get_penalties(self) -> list[Penalty]:
|
||||
@@ -538,9 +531,7 @@ class TaskMateStorage:
|
||||
|
||||
def remove_penalty(self, penalty_id: str) -> None:
|
||||
"""Remove a penalty."""
|
||||
self._data["penalties"] = [
|
||||
p for p in self._data.get("penalties", []) if p.get("id") != penalty_id
|
||||
]
|
||||
self._data["penalties"] = [p for p in self._data.get("penalties", []) if p.get("id") != penalty_id]
|
||||
|
||||
# Bonuses management
|
||||
def get_bonuses(self) -> list[Bonus]:
|
||||
@@ -569,9 +560,7 @@ class TaskMateStorage:
|
||||
|
||||
def remove_bonus(self, bonus_id: str) -> None:
|
||||
"""Remove a bonus."""
|
||||
self._data["bonuses"] = [
|
||||
b for b in self._data.get("bonuses", []) if b.get("id") != bonus_id
|
||||
]
|
||||
self._data["bonuses"] = [b for b in self._data.get("bonuses", []) if b.get("id") != bonus_id]
|
||||
|
||||
# Badges management
|
||||
def get_badges(self) -> list[Badge]:
|
||||
@@ -600,9 +589,7 @@ class TaskMateStorage:
|
||||
|
||||
def remove_badge(self, badge_id: str) -> None:
|
||||
"""Remove a badge and cascade-delete its awards."""
|
||||
self._data["badges"] = [
|
||||
b for b in self._data.get("badges", []) if b.get("id") != badge_id
|
||||
]
|
||||
self._data["badges"] = [b for b in self._data.get("badges", []) if b.get("id") != badge_id]
|
||||
self.remove_awards_for_badge(badge_id)
|
||||
|
||||
# Awarded badges management
|
||||
@@ -620,9 +607,7 @@ class TaskMateStorage:
|
||||
|
||||
def remove_awarded_badge(self, awarded_id: str) -> None:
|
||||
"""Remove an awarded-badge record by id."""
|
||||
self._data["awarded_badges"] = [
|
||||
a for a in self._data.get("awarded_badges", []) if a.get("id") != awarded_id
|
||||
]
|
||||
self._data["awarded_badges"] = [a for a in self._data.get("awarded_badges", []) if a.get("id") != awarded_id]
|
||||
|
||||
def remove_awards_for_badge(self, badge_id: str) -> None:
|
||||
"""Cascade-delete all awards referencing a badge id."""
|
||||
@@ -705,9 +690,7 @@ class TaskMateStorage:
|
||||
cfg = NotificationConfig(
|
||||
type_id=tid,
|
||||
master_enabled=True,
|
||||
routes={
|
||||
seeded_parent_id: NotificationRoute(enabled=True)
|
||||
} if seeded_parent_id else {},
|
||||
routes={seeded_parent_id: NotificationRoute(enabled=True)} if seeded_parent_id else {},
|
||||
)
|
||||
nc[tid] = cfg.to_dict()
|
||||
|
||||
@@ -728,8 +711,7 @@ class TaskMateStorage:
|
||||
|
||||
def delete_parent_recipient(self, parent_id: str) -> None:
|
||||
self._data["parent_recipients"] = [
|
||||
r for r in self._data.get("parent_recipients", [])
|
||||
if r.get("id") != parent_id
|
||||
r for r in self._data.get("parent_recipients", []) if r.get("id") != parent_id
|
||||
]
|
||||
|
||||
# --- notification config ---
|
||||
@@ -749,9 +731,7 @@ class TaskMateStorage:
|
||||
cfg.nav_url = nav_url
|
||||
self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict()
|
||||
|
||||
def set_notification_route(
|
||||
self, type_id: str, recipient_id: str, route: NotificationRoute
|
||||
) -> None:
|
||||
def set_notification_route(self, type_id: str, recipient_id: str, route: NotificationRoute) -> None:
|
||||
cfg = self.get_notification_config(type_id)
|
||||
cfg.routes[recipient_id] = route
|
||||
self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict()
|
||||
@@ -764,10 +744,7 @@ class TaskMateStorage:
|
||||
|
||||
# --- custom notifications ---
|
||||
def get_custom_notifications(self) -> list[CustomNotification]:
|
||||
return [
|
||||
CustomNotification.from_dict(d)
|
||||
for d in self._data.get("custom_notifications", [])
|
||||
]
|
||||
return [CustomNotification.from_dict(d) for d in self._data.get("custom_notifications", [])]
|
||||
|
||||
def upsert_custom_notification(self, n: CustomNotification) -> None:
|
||||
rows = self._data.setdefault("custom_notifications", [])
|
||||
@@ -779,15 +756,12 @@ class TaskMateStorage:
|
||||
|
||||
def delete_custom_notification(self, custom_id: str) -> None:
|
||||
self._data["custom_notifications"] = [
|
||||
r for r in self._data.get("custom_notifications", [])
|
||||
if r.get("id") != custom_id
|
||||
r for r in self._data.get("custom_notifications", []) if r.get("id") != custom_id
|
||||
]
|
||||
|
||||
# --- streak-at-risk cutoff ---
|
||||
def get_streak_at_risk_cutoff(self) -> str:
|
||||
return (self._data.get("settings", {}) or {}).get(
|
||||
"streak_at_risk_cutoff_time", "20:00"
|
||||
)
|
||||
return (self._data.get("settings", {}) or {}).get("streak_at_risk_cutoff_time", "20:00")
|
||||
|
||||
def set_streak_at_risk_cutoff(self, hhmm: str) -> None:
|
||||
self._data.setdefault("settings", {})["streak_at_risk_cutoff_time"] = hhmm
|
||||
@@ -811,16 +785,14 @@ class TaskMateStorage:
|
||||
def get_escalation_reminder_minutes(self) -> int:
|
||||
"""Minutes after a mandatory miss before the child reminder escalates."""
|
||||
try:
|
||||
return max(1, int((self._data.get("settings", {}) or {}).get(
|
||||
"mandatory_escalation_reminder_minutes", 30)))
|
||||
return max(1, int((self._data.get("settings", {}) or {}).get("mandatory_escalation_reminder_minutes", 30)))
|
||||
except (TypeError, ValueError):
|
||||
return 30
|
||||
|
||||
def get_escalation_parent_minutes(self) -> int:
|
||||
"""Minutes after a mandatory miss before the parent alert escalates."""
|
||||
try:
|
||||
return max(1, int((self._data.get("settings", {}) or {}).get(
|
||||
"mandatory_escalation_parent_minutes", 120)))
|
||||
return max(1, int((self._data.get("settings", {}) or {}).get("mandatory_escalation_parent_minutes", 120)))
|
||||
except (TypeError, ValueError):
|
||||
return 120
|
||||
|
||||
@@ -863,9 +835,7 @@ class TaskMateStorage:
|
||||
|
||||
def remove_task_group(self, group_id: str) -> None:
|
||||
"""Remove a task group."""
|
||||
self._data["task_groups"] = [
|
||||
g for g in self._data.get("task_groups", []) if g.get("id") != group_id
|
||||
]
|
||||
self._data["task_groups"] = [g for g in self._data.get("task_groups", []) if g.get("id") != group_id]
|
||||
|
||||
def remove_chore_from_task_groups(self, chore_id: str) -> None:
|
||||
"""Strip a chore ID from every group (used on chore delete)."""
|
||||
@@ -921,9 +891,7 @@ class TaskMateStorage:
|
||||
# the single choke point all awards flow through — the rolling 200-cap on
|
||||
# transactions makes them unreliable for a monthly total (FEAT-2).
|
||||
if transaction.points > 0:
|
||||
self.record_season_points(
|
||||
transaction.child_id, transaction.points, transaction.created_at
|
||||
)
|
||||
self.record_season_points(transaction.child_id, transaction.points, transaction.created_at)
|
||||
|
||||
# Keep only the last 200 transactions to avoid unbounded storage growth
|
||||
if len(self._data["points_transactions"]) > 200:
|
||||
@@ -1019,6 +987,57 @@ class TaskMateStorage:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _drop_dead_swap_requests(self) -> None:
|
||||
"""Prune swap requests that are settled or dangling (#783, #785).
|
||||
|
||||
Called from ``async_load``. A request survives only when it is still
|
||||
pending *and* every id it names still resolves — the chore, the
|
||||
requester, and ``from_child_id`` when set (it is "" for a chore with no
|
||||
cached assignee yet, which is a normal request, not a dead reference).
|
||||
"""
|
||||
swap_requests = self._data.get("swap_requests")
|
||||
if not swap_requests:
|
||||
return
|
||||
|
||||
chore_ids = {c.get("id") for c in self._data.get("chores", [])}
|
||||
child_ids = {c.get("id") for c in self._data.get("children", [])}
|
||||
|
||||
def _alive(req: dict) -> bool:
|
||||
if req.get("status") != "pending":
|
||||
return False
|
||||
if req.get("chore_id") not in chore_ids:
|
||||
return False
|
||||
if req.get("requester_id") not in child_ids:
|
||||
return False
|
||||
from_id = req.get("from_child_id") or ""
|
||||
return not from_id or from_id in child_ids
|
||||
|
||||
kept = [r for r in swap_requests if _alive(r)]
|
||||
if len(kept) != len(swap_requests):
|
||||
_LOGGER.debug(
|
||||
"Dropped %d settled or orphaned swap request(s) from storage",
|
||||
len(swap_requests) - len(kept),
|
||||
)
|
||||
self._data["swap_requests"] = kept
|
||||
|
||||
def remove_swap_requests_for_chore(self, chore_id: str) -> None:
|
||||
"""Drop a deleted chore's swap requests so they can't linger in the
|
||||
approval queue with nothing to approve."""
|
||||
self._data["swap_requests"] = [r for r in self._data.get("swap_requests", []) if r.get("chore_id") != chore_id]
|
||||
|
||||
def remove_swap_requests_for_child(self, child_id: str) -> None:
|
||||
"""Drop swap requests a deleted child is either side of.
|
||||
|
||||
Both ends matter: the requester is who the handover goes *to*, and
|
||||
`from_child_id` is who it comes from and is rendered in the queue.
|
||||
Either being gone makes the request undeliverable.
|
||||
"""
|
||||
self._data["swap_requests"] = [
|
||||
r
|
||||
for r in self._data.get("swap_requests", [])
|
||||
if r.get("requester_id") != child_id and r.get("from_child_id") != child_id
|
||||
]
|
||||
|
||||
# ── Quests (chore chains) ────────────────────────────────────────────
|
||||
def get_quests(self) -> list[Quest]:
|
||||
return [Quest.from_dict(q) for q in self._data.get("quests", [])]
|
||||
@@ -1041,9 +1060,7 @@ class TaskMateStorage:
|
||||
self.add_quest(quest)
|
||||
|
||||
def remove_quest(self, quest_id: str) -> None:
|
||||
self._data["quests"] = [
|
||||
q for q in self._data.get("quests", []) if q.get("id") != quest_id
|
||||
]
|
||||
self._data["quests"] = [q for q in self._data.get("quests", []) if q.get("id") != quest_id]
|
||||
# Drop any progress tracked for this quest
|
||||
prog = self._data.get("quest_progress", {})
|
||||
prog.pop(quest_id, None)
|
||||
@@ -1084,9 +1101,7 @@ class TaskMateStorage:
|
||||
self.add_challenge(challenge)
|
||||
|
||||
def remove_challenge(self, challenge_id: str) -> None:
|
||||
self._data["challenges"] = [
|
||||
c for c in self._data.get("challenges", []) if c.get("id") != challenge_id
|
||||
]
|
||||
self._data["challenges"] = [c for c in self._data.get("challenges", []) if c.get("id") != challenge_id]
|
||||
self._data.get("challenge_progress", {}).pop(challenge_id, None)
|
||||
|
||||
def get_challenge_progress(self) -> dict:
|
||||
@@ -1107,6 +1122,7 @@ class TaskMateStorage:
|
||||
def export_data(self) -> dict:
|
||||
"""Return a deep copy of the full stored data (for backup/export)."""
|
||||
import copy
|
||||
|
||||
return copy.deepcopy(self._data)
|
||||
|
||||
def import_data(self, data: dict) -> None:
|
||||
@@ -1116,14 +1132,29 @@ class TaskMateStorage:
|
||||
a partial import.
|
||||
"""
|
||||
import copy
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("import data must be an object")
|
||||
self._data = copy.deepcopy(data)
|
||||
list_keys = (
|
||||
"children", "chores", "rewards", "penalties", "bonuses",
|
||||
"task_groups", "completions", "mandatory_misses", "reward_claims", "points_transactions",
|
||||
"pool_allocations", "badges", "awarded_badges", "parent_recipients",
|
||||
"audit_log", "timed_sessions", "quests", "challenges",
|
||||
"children",
|
||||
"chores",
|
||||
"rewards",
|
||||
"penalties",
|
||||
"bonuses",
|
||||
"task_groups",
|
||||
"completions",
|
||||
"mandatory_misses",
|
||||
"reward_claims",
|
||||
"points_transactions",
|
||||
"pool_allocations",
|
||||
"badges",
|
||||
"awarded_badges",
|
||||
"parent_recipients",
|
||||
"audit_log",
|
||||
"timed_sessions",
|
||||
"quests",
|
||||
"challenges",
|
||||
)
|
||||
for k in list_keys:
|
||||
if not isinstance(self._data.get(k), list):
|
||||
@@ -1146,6 +1177,7 @@ class TaskMateStorage:
|
||||
well-formed photo URLs so the panel never renders a foreign/dangerous one.
|
||||
"""
|
||||
from .photos import is_taskmate_photo_url
|
||||
|
||||
for comp in self._data.get("completions", []):
|
||||
if not isinstance(comp, dict):
|
||||
continue
|
||||
@@ -1163,21 +1195,15 @@ class TaskMateStorage:
|
||||
|
||||
def remove_completions_for_child(self, child_id: str) -> None:
|
||||
"""Remove all completions for a given child."""
|
||||
self._data["completions"] = [
|
||||
c for c in self._data.get("completions", []) if c.get("child_id") != child_id
|
||||
]
|
||||
self._data["completions"] = [c for c in self._data.get("completions", []) if c.get("child_id") != child_id]
|
||||
|
||||
def remove_completions_for_chore(self, chore_id: str) -> None:
|
||||
"""Remove all completions for a given chore."""
|
||||
self._data["completions"] = [
|
||||
c for c in self._data.get("completions", []) if c.get("chore_id") != chore_id
|
||||
]
|
||||
self._data["completions"] = [c for c in self._data.get("completions", []) if c.get("chore_id") != chore_id]
|
||||
|
||||
def remove_reward_claims_for_child(self, child_id: str) -> None:
|
||||
"""Remove all reward claims for a given child."""
|
||||
self._data["reward_claims"] = [
|
||||
c for c in self._data.get("reward_claims", []) if c.get("child_id") != child_id
|
||||
]
|
||||
self._data["reward_claims"] = [c for c in self._data.get("reward_claims", []) if c.get("child_id") != child_id]
|
||||
|
||||
def remove_reward_claims_for_reward(self, reward_id: str) -> None:
|
||||
"""Remove all reward claims for a given reward."""
|
||||
@@ -1209,7 +1235,8 @@ class TaskMateStorage:
|
||||
def remove_pool_allocation(self, child_id: str, reward_id: str) -> None:
|
||||
"""Remove a pool allocation for a specific (child, reward) pair."""
|
||||
self._data["pool_allocations"] = [
|
||||
a for a in self._data.get("pool_allocations", [])
|
||||
a
|
||||
for a in self._data.get("pool_allocations", [])
|
||||
if not (a.get("child_id") == child_id and a.get("reward_id") == reward_id)
|
||||
]
|
||||
|
||||
@@ -1309,18 +1336,22 @@ class TaskMateStorage:
|
||||
def get_timed_session(self, chore_id: str, child_id: str, session_date: str) -> TimedSession | None:
|
||||
"""Get a timed session for a specific chore/child/date."""
|
||||
for s in self._data.get("timed_sessions", []):
|
||||
if (s.get("chore_id") == chore_id
|
||||
and s.get("child_id") == child_id
|
||||
and s.get("session_date") == session_date):
|
||||
if (
|
||||
s.get("chore_id") == chore_id
|
||||
and s.get("child_id") == child_id
|
||||
and s.get("session_date") == session_date
|
||||
):
|
||||
return TimedSession.from_dict(s)
|
||||
return None
|
||||
|
||||
def get_active_timed_session(self, chore_id: str, child_id: str) -> TimedSession | None:
|
||||
"""Get a running or paused session for a chore/child pair."""
|
||||
for s in self._data.get("timed_sessions", []):
|
||||
if (s.get("chore_id") == chore_id
|
||||
and s.get("child_id") == child_id
|
||||
and s.get("state") in ("running", "paused")):
|
||||
if (
|
||||
s.get("chore_id") == chore_id
|
||||
and s.get("child_id") == child_id
|
||||
and s.get("state") in ("running", "paused")
|
||||
):
|
||||
return TimedSession.from_dict(s)
|
||||
return None
|
||||
|
||||
@@ -1335,10 +1366,7 @@ class TaskMateStorage:
|
||||
|
||||
def remove_timed_session(self, session_id: str) -> None:
|
||||
"""Remove a timed session."""
|
||||
self._data["timed_sessions"] = [
|
||||
s for s in self._data.get("timed_sessions", [])
|
||||
if s.get("id") != session_id
|
||||
]
|
||||
self._data["timed_sessions"] = [s for s in self._data.get("timed_sessions", []) if s.get("id") != session_id]
|
||||
|
||||
# Generic settings
|
||||
def get_setting(self, key: str, default: Any = "") -> Any:
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
"""Built-in chore template packs for TaskMate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
TEMPLATE_CHORE_FIELDS = (
|
||||
"name", "points", "description", "requires_approval", "time_category",
|
||||
"daily_limit", "completion_sound", "schedule_mode", "due_days",
|
||||
"recurrence", "recurrence_day", "recurrence_start", "first_occurrence_mode",
|
||||
"assignment_mode", "require_availability", "visibility_entity",
|
||||
"visibility_state", "visibility_operator",
|
||||
"weather_entity", "weather_block_conditions", "weather_temp_min",
|
||||
"weather_temp_max", "weather_wind_max", "task_type",
|
||||
"timed_rate_points", "timed_rate_minutes", "timed_max_daily_minutes",
|
||||
"name",
|
||||
"points",
|
||||
"description",
|
||||
"requires_approval",
|
||||
"time_category",
|
||||
"daily_limit",
|
||||
"completion_sound",
|
||||
"schedule_mode",
|
||||
"due_days",
|
||||
"recurrence",
|
||||
"recurrence_day",
|
||||
"recurrence_start",
|
||||
"first_occurrence_mode",
|
||||
"assignment_mode",
|
||||
"require_availability",
|
||||
"visibility_entity",
|
||||
"visibility_state",
|
||||
"visibility_operator",
|
||||
"weather_entity",
|
||||
"weather_block_conditions",
|
||||
"weather_temp_min",
|
||||
"weather_temp_max",
|
||||
"weather_wind_max",
|
||||
"task_type",
|
||||
"timed_rate_points",
|
||||
"timed_rate_minutes",
|
||||
"timed_max_daily_minutes",
|
||||
)
|
||||
|
||||
_WEEKDAYS = ["monday", "tuesday", "wednesday", "thursday", "friday"]
|
||||
@@ -22,10 +42,50 @@ BUILT_IN_TEMPLATES: list[dict] = [
|
||||
"icon": "mdi:weather-sunny",
|
||||
"builtin": True,
|
||||
"chores": [
|
||||
{"name": "Make bed", "points": 2, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
||||
{"name": "Brush teeth", "points": 1, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
||||
{"name": "Get dressed", "points": 1, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
||||
{"name": "Pack school bag", "points": 2, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
||||
{
|
||||
"name": "Make bed",
|
||||
"points": 2,
|
||||
"time_category": "morning",
|
||||
"schedule_mode": "specific_days",
|
||||
"due_days": list(_WEEKDAYS),
|
||||
"requires_approval": False,
|
||||
"assignment_mode": "everyone",
|
||||
"daily_limit": 1,
|
||||
"completion_sound": "coin",
|
||||
},
|
||||
{
|
||||
"name": "Brush teeth",
|
||||
"points": 1,
|
||||
"time_category": "morning",
|
||||
"schedule_mode": "specific_days",
|
||||
"due_days": list(_WEEKDAYS),
|
||||
"requires_approval": False,
|
||||
"assignment_mode": "everyone",
|
||||
"daily_limit": 1,
|
||||
"completion_sound": "coin",
|
||||
},
|
||||
{
|
||||
"name": "Get dressed",
|
||||
"points": 1,
|
||||
"time_category": "morning",
|
||||
"schedule_mode": "specific_days",
|
||||
"due_days": list(_WEEKDAYS),
|
||||
"requires_approval": False,
|
||||
"assignment_mode": "everyone",
|
||||
"daily_limit": 1,
|
||||
"completion_sound": "coin",
|
||||
},
|
||||
{
|
||||
"name": "Pack school bag",
|
||||
"points": 2,
|
||||
"time_category": "morning",
|
||||
"schedule_mode": "specific_days",
|
||||
"due_days": list(_WEEKDAYS),
|
||||
"requires_approval": False,
|
||||
"assignment_mode": "everyone",
|
||||
"daily_limit": 1,
|
||||
"completion_sound": "coin",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -34,10 +94,50 @@ BUILT_IN_TEMPLATES: list[dict] = [
|
||||
"icon": "mdi:weather-night",
|
||||
"builtin": True,
|
||||
"chores": [
|
||||
{"name": "Brush teeth", "points": 1, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
||||
{"name": "Put on pyjamas", "points": 1, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
||||
{"name": "Tidy room", "points": 2, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
||||
{"name": "Set out clothes for tomorrow", "points": 1, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
||||
{
|
||||
"name": "Brush teeth",
|
||||
"points": 1,
|
||||
"time_category": "evening",
|
||||
"schedule_mode": "specific_days",
|
||||
"due_days": list(_WEEKDAYS),
|
||||
"requires_approval": False,
|
||||
"assignment_mode": "everyone",
|
||||
"daily_limit": 1,
|
||||
"completion_sound": "coin",
|
||||
},
|
||||
{
|
||||
"name": "Put on pyjamas",
|
||||
"points": 1,
|
||||
"time_category": "evening",
|
||||
"schedule_mode": "specific_days",
|
||||
"due_days": list(_WEEKDAYS),
|
||||
"requires_approval": False,
|
||||
"assignment_mode": "everyone",
|
||||
"daily_limit": 1,
|
||||
"completion_sound": "coin",
|
||||
},
|
||||
{
|
||||
"name": "Tidy room",
|
||||
"points": 2,
|
||||
"time_category": "evening",
|
||||
"schedule_mode": "specific_days",
|
||||
"due_days": list(_WEEKDAYS),
|
||||
"requires_approval": False,
|
||||
"assignment_mode": "everyone",
|
||||
"daily_limit": 1,
|
||||
"completion_sound": "coin",
|
||||
},
|
||||
{
|
||||
"name": "Set out clothes for tomorrow",
|
||||
"points": 1,
|
||||
"time_category": "evening",
|
||||
"schedule_mode": "specific_days",
|
||||
"due_days": list(_WEEKDAYS),
|
||||
"requires_approval": False,
|
||||
"assignment_mode": "everyone",
|
||||
"daily_limit": 1,
|
||||
"completion_sound": "coin",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -46,10 +146,50 @@ BUILT_IN_TEMPLATES: list[dict] = [
|
||||
"icon": "mdi:silverware-fork-knife",
|
||||
"builtin": True,
|
||||
"chores": [
|
||||
{"name": "Set table", "points": 2, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
||||
{"name": "Clear plates", "points": 2, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
||||
{"name": "Load dishwasher", "points": 3, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
||||
{"name": "Wipe counters", "points": 2, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
||||
{
|
||||
"name": "Set table",
|
||||
"points": 2,
|
||||
"time_category": "evening",
|
||||
"schedule_mode": "specific_days",
|
||||
"due_days": list(_WEEKDAYS),
|
||||
"requires_approval": False,
|
||||
"assignment_mode": "everyone",
|
||||
"daily_limit": 1,
|
||||
"completion_sound": "coin",
|
||||
},
|
||||
{
|
||||
"name": "Clear plates",
|
||||
"points": 2,
|
||||
"time_category": "evening",
|
||||
"schedule_mode": "specific_days",
|
||||
"due_days": list(_WEEKDAYS),
|
||||
"requires_approval": False,
|
||||
"assignment_mode": "everyone",
|
||||
"daily_limit": 1,
|
||||
"completion_sound": "coin",
|
||||
},
|
||||
{
|
||||
"name": "Load dishwasher",
|
||||
"points": 3,
|
||||
"time_category": "evening",
|
||||
"schedule_mode": "specific_days",
|
||||
"due_days": list(_WEEKDAYS),
|
||||
"requires_approval": False,
|
||||
"assignment_mode": "everyone",
|
||||
"daily_limit": 1,
|
||||
"completion_sound": "coin",
|
||||
},
|
||||
{
|
||||
"name": "Wipe counters",
|
||||
"points": 2,
|
||||
"time_category": "evening",
|
||||
"schedule_mode": "specific_days",
|
||||
"due_days": list(_WEEKDAYS),
|
||||
"requires_approval": False,
|
||||
"assignment_mode": "everyone",
|
||||
"daily_limit": 1,
|
||||
"completion_sound": "coin",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -58,10 +198,50 @@ BUILT_IN_TEMPLATES: list[dict] = [
|
||||
"icon": "mdi:broom",
|
||||
"builtin": True,
|
||||
"chores": [
|
||||
{"name": "Tidy bedroom", "points": 3, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKENDS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
||||
{"name": "Hoover", "points": 4, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKENDS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
||||
{"name": "Help with laundry", "points": 3, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKENDS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
||||
{"name": "Take bins out", "points": 2, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKENDS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
||||
{
|
||||
"name": "Tidy bedroom",
|
||||
"points": 3,
|
||||
"time_category": "anytime",
|
||||
"schedule_mode": "specific_days",
|
||||
"due_days": list(_WEEKENDS),
|
||||
"requires_approval": False,
|
||||
"assignment_mode": "everyone",
|
||||
"daily_limit": 1,
|
||||
"completion_sound": "coin",
|
||||
},
|
||||
{
|
||||
"name": "Hoover",
|
||||
"points": 4,
|
||||
"time_category": "anytime",
|
||||
"schedule_mode": "specific_days",
|
||||
"due_days": list(_WEEKENDS),
|
||||
"requires_approval": False,
|
||||
"assignment_mode": "everyone",
|
||||
"daily_limit": 1,
|
||||
"completion_sound": "coin",
|
||||
},
|
||||
{
|
||||
"name": "Help with laundry",
|
||||
"points": 3,
|
||||
"time_category": "anytime",
|
||||
"schedule_mode": "specific_days",
|
||||
"due_days": list(_WEEKENDS),
|
||||
"requires_approval": False,
|
||||
"assignment_mode": "everyone",
|
||||
"daily_limit": 1,
|
||||
"completion_sound": "coin",
|
||||
},
|
||||
{
|
||||
"name": "Take bins out",
|
||||
"points": 2,
|
||||
"time_category": "anytime",
|
||||
"schedule_mode": "specific_days",
|
||||
"due_days": list(_WEEKENDS),
|
||||
"requires_approval": False,
|
||||
"assignment_mode": "everyone",
|
||||
"daily_limit": 1,
|
||||
"completion_sound": "coin",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -70,10 +250,50 @@ BUILT_IN_TEMPLATES: list[dict] = [
|
||||
"icon": "mdi:paw",
|
||||
"builtin": True,
|
||||
"chores": [
|
||||
{"name": "Feed pet", "points": 2, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
||||
{"name": "Fill water bowl", "points": 1, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
||||
{"name": "Walk dog", "points": 3, "time_category": "afternoon", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
||||
{"name": "Clean litter tray", "points": 3, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
||||
{
|
||||
"name": "Feed pet",
|
||||
"points": 2,
|
||||
"time_category": "morning",
|
||||
"schedule_mode": "specific_days",
|
||||
"due_days": list(_WEEKDAYS),
|
||||
"requires_approval": False,
|
||||
"assignment_mode": "everyone",
|
||||
"daily_limit": 1,
|
||||
"completion_sound": "coin",
|
||||
},
|
||||
{
|
||||
"name": "Fill water bowl",
|
||||
"points": 1,
|
||||
"time_category": "morning",
|
||||
"schedule_mode": "specific_days",
|
||||
"due_days": list(_WEEKDAYS),
|
||||
"requires_approval": False,
|
||||
"assignment_mode": "everyone",
|
||||
"daily_limit": 1,
|
||||
"completion_sound": "coin",
|
||||
},
|
||||
{
|
||||
"name": "Walk dog",
|
||||
"points": 3,
|
||||
"time_category": "afternoon",
|
||||
"schedule_mode": "specific_days",
|
||||
"due_days": list(_WEEKDAYS),
|
||||
"requires_approval": False,
|
||||
"assignment_mode": "everyone",
|
||||
"daily_limit": 1,
|
||||
"completion_sound": "coin",
|
||||
},
|
||||
{
|
||||
"name": "Clean litter tray",
|
||||
"points": 3,
|
||||
"time_category": "anytime",
|
||||
"schedule_mode": "specific_days",
|
||||
"due_days": list(_WEEKDAYS),
|
||||
"requires_approval": False,
|
||||
"assignment_mode": "everyone",
|
||||
"daily_limit": 1,
|
||||
"completion_sound": "coin",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -82,9 +302,39 @@ BUILT_IN_TEMPLATES: list[dict] = [
|
||||
"icon": "mdi:book-open-variant",
|
||||
"builtin": True,
|
||||
"chores": [
|
||||
{"name": "Do homework", "points": 3, "time_category": "afternoon", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
||||
{"name": "Read for 20 minutes", "points": 2, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
||||
{"name": "Practice instrument", "points": 2, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"},
|
||||
{
|
||||
"name": "Do homework",
|
||||
"points": 3,
|
||||
"time_category": "afternoon",
|
||||
"schedule_mode": "specific_days",
|
||||
"due_days": list(_WEEKDAYS),
|
||||
"requires_approval": False,
|
||||
"assignment_mode": "everyone",
|
||||
"daily_limit": 1,
|
||||
"completion_sound": "coin",
|
||||
},
|
||||
{
|
||||
"name": "Read for 20 minutes",
|
||||
"points": 2,
|
||||
"time_category": "anytime",
|
||||
"schedule_mode": "specific_days",
|
||||
"due_days": list(_WEEKDAYS),
|
||||
"requires_approval": False,
|
||||
"assignment_mode": "everyone",
|
||||
"daily_limit": 1,
|
||||
"completion_sound": "coin",
|
||||
},
|
||||
{
|
||||
"name": "Practice instrument",
|
||||
"points": 2,
|
||||
"time_category": "anytime",
|
||||
"schedule_mode": "specific_days",
|
||||
"due_days": list(_WEEKDAYS),
|
||||
"requires_approval": False,
|
||||
"assignment_mode": "everyone",
|
||||
"daily_limit": 1,
|
||||
"completion_sound": "coin",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -5,6 +5,7 @@ today (via the shared get_due_chores_for_child). Checking an item off completes
|
||||
the chore, so the native HA to-do card and voice assistants can drive TaskMate
|
||||
without the custom cards.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.todo import (
|
||||
@@ -23,9 +24,7 @@ from .const import DOMAIN
|
||||
from .coordinator import TaskMateCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||
"""Set up a to-do list per child, adding new children as they appear."""
|
||||
coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
tracked: set[str] = set()
|
||||
|
||||
+1016
-683
File diff suppressed because it is too large
Load Diff
@@ -153,6 +153,7 @@
|
||||
"badges.editor_help": "Konfigurieren Sie per YAML: Setzen Sie entity auf Ihren Badges-Sensor (z.B. sensor.taskmate_badges_mia).",
|
||||
"badges.entity_not_found": "{entity} nicht gefunden",
|
||||
"badges.label": "Abzeichen",
|
||||
"badges.next_up": "Als Nächstes",
|
||||
"badges.title_with_name": "Abzeichen von {name}",
|
||||
"bonuses.add_bonus": "Bonus hinzufügen",
|
||||
"bonuses.applying_to": "Beantragung bei {childName} – aktuelles Guthaben: {points} {pointsName}",
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"badges.editor_help": "Configure via YAML: set entity to your badges sensor (e.g. sensor.taskmate_badges_mia).",
|
||||
"badges.entity_not_found": "{entity} not found",
|
||||
"badges.label": "Badges",
|
||||
"badges.next_up": "Next up",
|
||||
"badges.title_with_name": "{name}'s Badges",
|
||||
"bonuses.add_bonus": "Add Bonus",
|
||||
"bonuses.applying_to": "Applying to {childName} — current balance: {points} {pointsName}",
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"badges.editor_help": "Configure via YAML: set entity to your badges sensor (e.g. sensor.taskmate_badges_mia).",
|
||||
"badges.entity_not_found": "{entity} not found",
|
||||
"badges.label": "Badges",
|
||||
"badges.next_up": "Next up",
|
||||
"badges.title_with_name": "{name}'s Badges",
|
||||
"bonuses.add_bonus": "Add Bonus",
|
||||
"bonuses.applying_to": "Applying to {childName} — current balance: {points} {pointsName}",
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
"badges.editor_help": "Configurez via YAML : définissez entity sur votre capteur de badges (ex. sensor.taskmate_badges_mia).",
|
||||
"badges.entity_not_found": "{entity} introuvable",
|
||||
"badges.label": "Badges",
|
||||
"badges.next_up": "Prochain",
|
||||
"badges.title_with_name": "Badges de {name}",
|
||||
"bonuses.add_bonus": "Ajouter un bonus",
|
||||
"bonuses.applying_to": "Application à {childName} — solde actuel : {points} {pointsName}",
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
"badges.editor_help": "Konfigurer via YAML: sett entity til din badge-sensor (f.eks. sensor.taskmate_badges_mia).",
|
||||
"badges.entity_not_found": "{entity} ikke funnet",
|
||||
"badges.label": "Merker",
|
||||
"badges.next_up": "Neste",
|
||||
"badges.title_with_name": "{name}s merker",
|
||||
"bonuses.add_bonus": "Legg til bonus",
|
||||
"bonuses.applying_to": "Brukes på {childName} — nåværende saldo: {points} {pointsName}",
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
"badges.editor_help": "Konfigurer via YAML: sett entity til din badge-sensor (t.d. sensor.taskmate_badges_mia).",
|
||||
"badges.entity_not_found": "{entity} ikkje funne",
|
||||
"badges.label": "Merke",
|
||||
"badges.next_up": "Neste",
|
||||
"badges.title_with_name": "Merka til {name}",
|
||||
"bonuses.add_bonus": "Legg til bonus",
|
||||
"bonuses.applying_to": "Vert brukt på {childName} — noverande saldo: {points} {pointsName}",
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
"badges.editor_help": "Configure via YAML: defina entity para o sensor de emblemas (ex. sensor.taskmate_badges_mia).",
|
||||
"badges.entity_not_found": "{entity} não encontrado",
|
||||
"badges.label": "Conquistas",
|
||||
"badges.next_up": "A seguir",
|
||||
"badges.title_with_name": "Conquistas de {name}",
|
||||
"bonuses.add_bonus": "Adicionar Bónus",
|
||||
"bonuses.applying_to": "A aplicar a {childName} — saldo atual: {points} {pointsName}",
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
"badges.editor_help": "Configure via YAML: defina entity para o sensor de emblemas (ex. sensor.taskmate_badges_mia).",
|
||||
"badges.entity_not_found": "{entity} não encontrado",
|
||||
"badges.label": "Conquistas",
|
||||
"badges.next_up": "A seguir",
|
||||
"badges.title_with_name": "Conquistas de {name}",
|
||||
"bonuses.add_bonus": "Adicionar Bónus",
|
||||
"bonuses.applying_to": "A aplicar a {childName} — saldo atual: {points} {pointsName}",
|
||||
|
||||
@@ -160,6 +160,13 @@ class TaskMateChildCard extends LitElement {
|
||||
}[tier] || '#888';
|
||||
}
|
||||
|
||||
_badgeKeydown(e) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
this._openBadgesView();
|
||||
}
|
||||
}
|
||||
|
||||
_openBadgesView() {
|
||||
const slug = this.config?.child_id
|
||||
? String(this.config.child_id).toLowerCase().replace(/\s+/g, '_')
|
||||
@@ -1906,6 +1913,74 @@ class TaskMateChildCard extends LitElement {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Next badge progress (#780) ── */
|
||||
.next-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--divider-color, #e0e0e0);
|
||||
}
|
||||
.next-badge:hover { background: var(--secondary-background-color, rgba(0,0,0,0.03)); }
|
||||
.next-badge:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.next-badge-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
--mdc-icon-size: 16px;
|
||||
color: #1a1a1a;
|
||||
border: 2px dashed var(--t);
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.next-badge-body { flex: 1; min-width: 0; }
|
||||
.next-badge-top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.next-badge-label {
|
||||
font-size: 11px;
|
||||
color: var(--secondary-text-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.next-badge-name {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-text-color);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.next-badge-count {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--secondary-text-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.next-badge-bar {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--divider-color, #e0e0e0);
|
||||
overflow: hidden;
|
||||
}
|
||||
.next-badge-bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
DESIGNED STYLES (playroom / console / cleanpro)
|
||||
Shared .tmd kit + tokens come from taskmate-design.js styles().
|
||||
@@ -2042,6 +2117,49 @@ class TaskMateChildCard extends LitElement {
|
||||
.tmd-badge-mini ha-icon { --mdc-icon-size: 16px; color: #fff; }
|
||||
.tmd-badges .more { font-size: 11px; font-weight: 800; color: var(--tmd-accent); }
|
||||
|
||||
/* Designed: next badge progress (#780) */
|
||||
.tmd-next-badge {
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
padding: 9px 11px; margin-bottom: 11px;
|
||||
background: var(--tmd-surface-2); border: 1px solid var(--tmd-border);
|
||||
border-radius: var(--tmd-radius-sm); cursor: pointer;
|
||||
}
|
||||
.tmd-next-badge:focus-visible { outline: 2px solid var(--tmd-accent); outline-offset: 1px; }
|
||||
.tmd-next-badge .ic {
|
||||
width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center;
|
||||
border: 2px dashed var(--t, var(--tmd-accent)); color: var(--tmd-dim); flex-shrink: 0;
|
||||
}
|
||||
.tmd-next-badge .ic ha-icon { --mdc-icon-size: 15px; }
|
||||
.tmd-next-badge .bd { flex: 1; min-width: 0; }
|
||||
.tmd-next-badge .top { display: flex; align-items: baseline; gap: 6px; margin-bottom: 5px; }
|
||||
.tmd-next-badge .lbl {
|
||||
font-size: 11px; font-weight: 800; color: var(--tmd-dim);
|
||||
text-transform: uppercase; letter-spacing: .04em; white-space: nowrap;
|
||||
}
|
||||
.tmd-next-badge .nm {
|
||||
font-size: 12.5px; font-weight: 700; color: var(--tmd-text);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.tmd-next-badge .cnt {
|
||||
margin-left: auto; font-size: 11px; font-weight: 800;
|
||||
color: var(--tmd-dim); white-space: nowrap;
|
||||
}
|
||||
.tmd-next-badge .bar {
|
||||
height: 6px; border-radius: 999px; background: var(--tmd-border); overflow: hidden;
|
||||
}
|
||||
.tmd-next-badge .bar i {
|
||||
display: block; height: 100%; border-radius: 999px; transition: width .4s ease;
|
||||
}
|
||||
/* Playroom is chunkier, console squares everything off, accessible needs
|
||||
a solid ring rather than a dashed one to stay legible. */
|
||||
:host([data-tm-design="playroom"]) .tmd-next-badge .bar { height: 8px; }
|
||||
:host([data-tm-design="console"]) .tmd-next-badge .bar,
|
||||
:host([data-tm-design="console"]) .tmd-next-badge .bar i { border-radius: 0; }
|
||||
:host([data-tm-design="accessible"]) .tmd-next-badge .ic { border-style: solid; }
|
||||
:host([data-tm-design="accessible"]) .tmd-next-badge .bar { height: 10px; }
|
||||
:host([data-tm-design="accessible"]) .tmd-next-badge .nm,
|
||||
:host([data-tm-design="accessible"]) .tmd-next-badge .cnt { font-size: 14px; }
|
||||
|
||||
/* Designed: vacation banner + swappable section */
|
||||
.tmd-vacation {
|
||||
display: flex; align-items: center; gap: 7px; padding: 9px 11px; margin-bottom: 11px;
|
||||
@@ -2072,6 +2190,7 @@ class TaskMateChildCard extends LitElement {
|
||||
show_countdown: true, // Show midnight reset countdown below section title
|
||||
show_due_days_only: true, // Whether to apply due_days filtering at all
|
||||
show_badges: true, // Show badge strip between points and chores
|
||||
show_next_badge: true, // Show progress toward the closest unearned badge
|
||||
header_color: '#9b59b6',
|
||||
...config,
|
||||
};
|
||||
@@ -2172,6 +2291,7 @@ class TaskMateChildCard extends LitElement {
|
||||
const badgesEntity = this._resolveBadgesEntity(child);
|
||||
const earnedBadges = (badgesEntity?.attributes?.earned) || [];
|
||||
const showBadges = this.config.show_badges !== false && earnedBadges.length > 0;
|
||||
const nextBadge = this._nextBadge(badgesEntity);
|
||||
|
||||
// Get pending points for this child
|
||||
const pendingPoints = child.pending_points || 0;
|
||||
@@ -2256,6 +2376,28 @@ class TaskMateChildCard extends LitElement {
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${nextBadge ? html`
|
||||
<div class="next-badge" role="button" tabindex="0"
|
||||
aria-label="${this._t('badges.next_up')} — ${nextBadge.name} ${nextBadge.label}"
|
||||
@click=${() => this._openBadgesView()}
|
||||
@keydown=${(e) => this._badgeKeydown(e)}>
|
||||
<div class="next-badge-icon" style="--t: ${this._tierColor(nextBadge.badge.tier)}">
|
||||
<ha-icon icon="${nextBadge.badge.icon || 'mdi:trophy-outline'}"></ha-icon>
|
||||
</div>
|
||||
<div class="next-badge-body">
|
||||
<div class="next-badge-top">
|
||||
<span class="next-badge-label">${this._t('badges.next_up')}</span>
|
||||
<span class="next-badge-name">${nextBadge.name}</span>
|
||||
<span class="next-badge-count">${nextBadge.label}</span>
|
||||
</div>
|
||||
<div class="next-badge-bar" role="progressbar"
|
||||
aria-valuenow="${nextBadge.pct}" aria-valuemin="0" aria-valuemax="100">
|
||||
<i style="width: ${nextBadge.pct}%; background: ${this._tierColor(nextBadge.badge.tier)}"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="chores-container">
|
||||
${childChores.length === 0
|
||||
? this._renderEmptyState()
|
||||
@@ -2346,6 +2488,39 @@ class TaskMateChildCard extends LitElement {
|
||||
return null;
|
||||
}
|
||||
|
||||
/* The single closest unearned badge (#780) — the kid-facing "next up" line.
|
||||
`available[]` is already sorted by nothing in particular, so pick the
|
||||
highest progress_pct. Badges nobody has started (0%) are skipped: a bar
|
||||
stuck at zero is noise, not motivation. Returns null when there is
|
||||
nothing worth showing. */
|
||||
_nextBadge(badgesEntity) {
|
||||
if (this.config.show_next_badge === false) return null;
|
||||
const available = badgesEntity?.attributes?.available || [];
|
||||
let best = null;
|
||||
for (const b of available) {
|
||||
const pct = Math.max(0, Math.min(100, Number(b.progress_pct) || 0));
|
||||
if (pct <= 0) continue;
|
||||
if (!best || pct > best.pct) best = { badge: b, pct };
|
||||
}
|
||||
if (!best) return null;
|
||||
const c = best.badge.closest_criterion;
|
||||
// Older backends (and criteria-free, manual-award badges) have no
|
||||
// closest_criterion — fall back to the percentage.
|
||||
best.label = c && c.target ? `${c.current} / ${c.target}` : `${best.pct}%`;
|
||||
best.name = this._badgeName(best.badge);
|
||||
return best;
|
||||
}
|
||||
|
||||
// Built-in badge names arrive from the sensor in English; the localised
|
||||
// name lives under badge.name_<suffix> (same scheme as the panel).
|
||||
_badgeName(b) {
|
||||
const id = b.badge_id || "";
|
||||
if (!id.startsWith("builtin.")) return b.name;
|
||||
const key = "badge.name_" + id.slice("builtin.".length);
|
||||
const t = this._t(key);
|
||||
return t !== key ? t : b.name;
|
||||
}
|
||||
|
||||
_designTone(i) { return `var(--tmd-c${(i % 6) + 1})`; }
|
||||
|
||||
_av(child, tone, size) {
|
||||
@@ -2489,6 +2664,7 @@ class TaskMateChildCard extends LitElement {
|
||||
const badgesEntity = this._resolveBadgesEntity(child);
|
||||
const earnedBadges = (badgesEntity?.attributes?.earned) || [];
|
||||
const showBadges = this.config.show_badges !== false && earnedBadges.length > 0;
|
||||
const nextBadge = this._nextBadge(badgesEntity);
|
||||
const pendingPoints = child.pending_points || 0;
|
||||
const countdown = this.config.show_countdown !== false ? this._getMidnightCountdown() : null;
|
||||
|
||||
@@ -2532,6 +2708,26 @@ class TaskMateChildCard extends LitElement {
|
||||
</div>`)}
|
||||
${earnedBadges.length > 5 ? html`<span class="more">+${earnedBadges.length - 5} →</span>` : ""}
|
||||
</div>` : ""}
|
||||
${nextBadge ? html`
|
||||
<div class="tmd-next-badge" role="button" tabindex="0"
|
||||
aria-label="${this._t("badges.next_up")} — ${nextBadge.name} ${nextBadge.label}"
|
||||
@click=${() => this._openBadgesView()}
|
||||
@keydown=${(e) => this._badgeKeydown(e)}>
|
||||
<div class="ic" style="--t:${this._tierColor(nextBadge.badge.tier)}">
|
||||
<ha-icon icon="${nextBadge.badge.icon || "mdi:trophy-outline"}"></ha-icon>
|
||||
</div>
|
||||
<div class="bd">
|
||||
<div class="top">
|
||||
<span class="lbl">${this._t("badges.next_up")}</span>
|
||||
<span class="nm">${nextBadge.name}</span>
|
||||
<span class="cnt">${nextBadge.label}</span>
|
||||
</div>
|
||||
<div class="bar" role="progressbar"
|
||||
aria-valuenow="${nextBadge.pct}" aria-valuemin="0" aria-valuemax="100">
|
||||
<i style="width:${nextBadge.pct}%;background:${this._tierColor(nextBadge.badge.tier)}"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>` : ""}
|
||||
${sectionLine}
|
||||
${this._renderRoulette(child, childChores, pointsIcon)}
|
||||
${body}
|
||||
@@ -4668,4 +4864,4 @@ console.info(
|
||||
"%c TASKMATE CHILD CARD %c v" + _tmVersion + " ",
|
||||
"background:#9b59b6;color:white;font-weight:bold;padding:2px 4px;border-radius:4px 0 0 4px;",
|
||||
"background:#2c3e50;color:white;font-weight:bold;padding:2px 4px;border-radius:0 4px 4px 0;"
|
||||
);
|
||||
);
|
||||
|
||||
+21
-21
@@ -15,9 +15,9 @@
|
||||
"latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
|
||||
"latest_release_notes": null
|
||||
},
|
||||
"power": 0.5,
|
||||
"power": 0.4,
|
||||
"linkquality": 116,
|
||||
"current": 1.23,
|
||||
"current": 0.03,
|
||||
"power_on_behavior": "on"
|
||||
},
|
||||
"0xffffb40e0607af27": {
|
||||
@@ -27,7 +27,7 @@
|
||||
"led_brightness": 100,
|
||||
"countdown_to_turn_off": 0,
|
||||
"countdown_to_turn_on": 0,
|
||||
"power": 2.1,
|
||||
"power": 2.7,
|
||||
"current": 0.12,
|
||||
"energy": 28.37,
|
||||
"power_factor": 0.18,
|
||||
@@ -45,10 +45,10 @@
|
||||
"state": "ON",
|
||||
"led_brightness": 100,
|
||||
"countdown_to_turn_off": 0,
|
||||
"voltage": 120.7,
|
||||
"voltage": 121.1,
|
||||
"countdown_to_turn_on": 0,
|
||||
"energy": 55.33,
|
||||
"power_factor": 0.89,
|
||||
"energy": 55.35,
|
||||
"power_factor": 0.2,
|
||||
"ac_frequency": 60,
|
||||
"update": {
|
||||
"state": "idle",
|
||||
@@ -58,8 +58,8 @@
|
||||
"latest_release_notes": null
|
||||
},
|
||||
"linkquality": 134,
|
||||
"power": 84.6,
|
||||
"current": 0.8,
|
||||
"power": 0.5,
|
||||
"current": 0.04,
|
||||
"power_on_behavior": "on"
|
||||
},
|
||||
"0xb40e060fffe031e3": {
|
||||
@@ -74,13 +74,13 @@
|
||||
"led_brightness": 100,
|
||||
"countdown_to_turn_off": 0,
|
||||
"countdown_to_turn_on": 0,
|
||||
"voltage": 120.5,
|
||||
"voltage": 120.3,
|
||||
"state": "ON",
|
||||
"ac_frequency": 60,
|
||||
"energy": 113.64,
|
||||
"energy": 113.67,
|
||||
"power": 0.8,
|
||||
"current": 0.05,
|
||||
"power_factor": 0.41,
|
||||
"current": 0.87,
|
||||
"power_factor": 0.34,
|
||||
"update": {
|
||||
"state": "idle",
|
||||
"installed_version": 268513381,
|
||||
@@ -96,12 +96,12 @@
|
||||
"countdown_to_turn_off": 0,
|
||||
"countdown_to_turn_on": 0,
|
||||
"voltage": 121.3,
|
||||
"energy": 50.5,
|
||||
"energy": 50.52,
|
||||
"state": "ON",
|
||||
"power": 44.2,
|
||||
"current": 0.53,
|
||||
"power": 38.6,
|
||||
"current": 0.42,
|
||||
"ac_frequency": 60,
|
||||
"power_factor": 0.76,
|
||||
"power_factor": 0.74,
|
||||
"update": {
|
||||
"state": "idle",
|
||||
"installed_version": 268513381,
|
||||
@@ -114,11 +114,11 @@
|
||||
},
|
||||
"0xffffb40e060895b3": {
|
||||
"state": "ON",
|
||||
"voltage": 121,
|
||||
"voltage": 121.4,
|
||||
"ac_frequency": 60,
|
||||
"energy": 7.14,
|
||||
"current": 0.01,
|
||||
"power": 0.2,
|
||||
"power": 0.1,
|
||||
"power_factor": 0.11,
|
||||
"linkquality": 123,
|
||||
"update": {
|
||||
@@ -172,7 +172,7 @@
|
||||
"0xffffb40e060893d8": {
|
||||
"state": "ON",
|
||||
"led_brightness": 100,
|
||||
"voltage": 121.2,
|
||||
"voltage": 121.7,
|
||||
"countdown_to_turn_off": 0,
|
||||
"countdown_to_turn_on": 0,
|
||||
"energy": 3.11,
|
||||
@@ -187,8 +187,8 @@
|
||||
"latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
|
||||
"latest_release_notes": null
|
||||
},
|
||||
"power_factor": 0,
|
||||
"power": 0
|
||||
"power_factor": 0.1,
|
||||
"power": 0.1
|
||||
},
|
||||
"0xa4c1380d0679ffff": {
|
||||
"battery": 100,
|
||||
|
||||
Reference in New Issue
Block a user