From 457f19d210555cb0efa9176ccebfef391729c76b Mon Sep 17 00:00:00 2001 From: Home Assistant Version Control Date: Thu, 13 Aug 2026 19:52:24 +0000 Subject: [PATCH] 46 files --- custom_components/taskmate/__init__.py | 353 ++-- custom_components/taskmate/binary_sensor.py | 1 + custom_components/taskmate/button.py | 19 +- custom_components/taskmate/calendar.py | 41 +- custom_components/taskmate/config_flow.py | 5 +- custom_components/taskmate/const.py | 75 +- .../taskmate/coord_assignments.py | 100 +- custom_components/taskmate/coord_avatars.py | 49 +- custom_components/taskmate/coord_badges.py | 160 +- custom_components/taskmate/coord_calendar.py | 43 +- .../taskmate/coord_challenges.py | 65 +- custom_components/taskmate/coord_chores.py | 336 ++-- custom_components/taskmate/coord_mandatory.py | 85 +- .../taskmate/coord_notifications.py | 161 +- custom_components/taskmate/coord_points.py | 268 +-- custom_components/taskmate/coord_quests.py | 59 +- custom_components/taskmate/coord_rewards.py | 134 +- custom_components/taskmate/coord_templates.py | 43 +- custom_components/taskmate/coord_timed.py | 14 +- custom_components/taskmate/coordinator.py | 188 +- custom_components/taskmate/frontend.py | 39 +- custom_components/taskmate/http_calendar.py | 2 + custom_components/taskmate/http_photos.py | 21 +- custom_components/taskmate/ics.py | 45 +- custom_components/taskmate/intents.py | 2 + custom_components/taskmate/manifest.json | 2 +- custom_components/taskmate/models.py | 48 +- custom_components/taskmate/number.py | 5 +- custom_components/taskmate/panel.py | 1 + custom_components/taskmate/photos.py | 10 +- custom_components/taskmate/select.py | 13 +- custom_components/taskmate/sensor.py | 568 +++--- custom_components/taskmate/storage.py | 222 ++- custom_components/taskmate/templates.py | 312 ++- custom_components/taskmate/todo.py | 5 +- custom_components/taskmate/websocket.py | 1699 ++++++++++------- .../taskmate/www/locales/de.json | 1 + .../taskmate/www/locales/en-GB.json | 1 + .../taskmate/www/locales/en.json | 1 + .../taskmate/www/locales/fr.json | 1 + .../taskmate/www/locales/nb.json | 1 + .../taskmate/www/locales/nn.json | 1 + .../taskmate/www/locales/pt-BR.json | 1 + .../taskmate/www/locales/pt.json | 1 + .../taskmate/www/taskmate-child-card.js | 198 +- zigbee2mqtt/state.json | 42 +- 46 files changed, 3333 insertions(+), 2108 deletions(-) diff --git a/custom_components/taskmate/__init__.py b/custom_components/taskmate/__init__.py index d2195fd8..bfd10f69 100644 --- a/custom_components/taskmate/__init__.py +++ b/custom_components/taskmate/__init__.py @@ -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( diff --git a/custom_components/taskmate/binary_sensor.py b/custom_components/taskmate/binary_sensor.py index b6ef3346..888c4e9a 100644 --- a/custom_components/taskmate/binary_sensor.py +++ b/custom_components/taskmate/binary_sensor.py @@ -1,4 +1,5 @@ """Binary sensor platform for TaskMate integration.""" + from __future__ import annotations from homeassistant.components.binary_sensor import BinarySensorEntity diff --git a/custom_components/taskmate/button.py b/custom_components/taskmate/button.py index fd3f5960..039fe7b3 100644 --- a/custom_components/taskmate/button.py +++ b/custom_components/taskmate/button.py @@ -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: diff --git a/custom_components/taskmate/calendar.py b/custom_components/taskmate/calendar.py index 34519411..25a420c6 100644 --- a/custom_components/taskmate/calendar.py +++ b/custom_components/taskmate/calendar.py @@ -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 diff --git a/custom_components/taskmate/config_flow.py b/custom_components/taskmate/config_flow.py index 10806a25..947682f9 100644 --- a/custom_components/taskmate/config_flow.py +++ b/custom_components/taskmate/config_flow.py @@ -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] = {} diff --git a/custom_components/taskmate/const.py b/custom_components/taskmate/const.py index b8ba6359..9cb6768a 100644 --- a/custom_components/taskmate/const.py +++ b/custom_components/taskmate/const.py @@ -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" diff --git a/custom_components/taskmate/coord_assignments.py b/custom_components/taskmate/coord_assignments.py index c3f0177e..2ea02625 100644 --- a/custom_components/taskmate/coord_assignments.py +++ b/custom_components/taskmate/coord_assignments.py @@ -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 diff --git a/custom_components/taskmate/coord_avatars.py b/custom_components/taskmate/coord_avatars.py index 4f59cae4..ff0be77b 100644 --- a/custom_components/taskmate/coord_avatars.py +++ b/custom_components/taskmate/coord_avatars.py @@ -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() diff --git a/custom_components/taskmate/coord_badges.py b/custom_components/taskmate/coord_badges.py index 9e64dba2..4b7252da 100644 --- a/custom_components/taskmate/coord_badges.py +++ b/custom_components/taskmate/coord_badges.py @@ -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 diff --git a/custom_components/taskmate/coord_calendar.py b/custom_components/taskmate/coord_calendar.py index b9d2e79e..8422549f 100644 --- a/custom_components/taskmate/coord_calendar.py +++ b/custom_components/taskmate/coord_calendar.py @@ -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, diff --git a/custom_components/taskmate/coord_challenges.py b/custom_components/taskmate/coord_challenges.py index 2ac7f2ea..1563d7ca 100644 --- a/custom_components/taskmate/coord_challenges.py +++ b/custom_components/taskmate/coord_challenges.py @@ -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) diff --git a/custom_components/taskmate/coord_chores.py b/custom_components/taskmate/coord_chores.py index 218a60c8..0ba8d0d4 100644 --- a/custom_components/taskmate/coord_chores.py +++ b/custom_components/taskmate/coord_chores.py @@ -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 []) diff --git a/custom_components/taskmate/coord_mandatory.py b/custom_components/taskmate/coord_mandatory.py index 3b4d24d7..1afab6fb 100644 --- a/custom_components/taskmate/coord_mandatory.py +++ b/custom_components/taskmate/coord_mandatory.py @@ -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: diff --git a/custom_components/taskmate/coord_notifications.py b/custom_components/taskmate/coord_notifications.py index fbf7fb31..c8f64a7e 100644 --- a/custom_components/taskmate/coord_notifications.py +++ b/custom_components/taskmate/coord_notifications.py @@ -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: diff --git a/custom_components/taskmate/coord_points.py b/custom_components/taskmate/coord_points.py index 700706aa..f4051eb2 100644 --- a/custom_components/taskmate/coord_points.py +++ b/custom_components/taskmate/coord_points.py @@ -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( diff --git a/custom_components/taskmate/coord_quests.py b/custom_components/taskmate/coord_quests.py index de20dad3..ca577406 100644 --- a/custom_components/taskmate/coord_quests.py +++ b/custom_components/taskmate/coord_quests.py @@ -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. diff --git a/custom_components/taskmate/coord_rewards.py b/custom_components/taskmate/coord_rewards.py index 4443e143..fb6fad04 100644 --- a/custom_components/taskmate/coord_rewards.py +++ b/custom_components/taskmate/coord_rewards.py @@ -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: diff --git a/custom_components/taskmate/coord_templates.py b/custom_components/taskmate/coord_templates.py index 0255c7dc..a5be535b 100644 --- a/custom_components/taskmate/coord_templates.py +++ b/custom_components/taskmate/coord_templates.py @@ -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: diff --git a/custom_components/taskmate/coord_timed.py b/custom_components/taskmate/coord_timed.py index 70aa3a5b..bccc105f 100644 --- a/custom_components/taskmate/coord_timed.py +++ b/custom_components/taskmate/coord_timed.py @@ -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() diff --git a/custom_components/taskmate/coordinator.py b/custom_components/taskmate/coordinator.py index e902814c..a6ea9f17 100644 --- a/custom_components/taskmate/coordinator.py +++ b/custom_components/taskmate/coordinator.py @@ -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() diff --git a/custom_components/taskmate/frontend.py b/custom_components/taskmate/frontend.py index d79179b4..cb8efada 100644 --- a/custom_components/taskmate/frontend.py +++ b/custom_components/taskmate/frontend.py @@ -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: diff --git a/custom_components/taskmate/http_calendar.py b/custom_components/taskmate/http_calendar.py index 6ebb115f..6f900af3 100644 --- a/custom_components/taskmate/http_calendar.py +++ b/custom_components/taskmate/http_calendar.py @@ -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 diff --git a/custom_components/taskmate/http_photos.py b/custom_components/taskmate/http_photos.py index 3073ce92..1d9f8d79 100644 --- a/custom_components/taskmate/http_photos.py +++ b/custom_components/taskmate/http_photos.py @@ -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}"}) diff --git a/custom_components/taskmate/ics.py b/custom_components/taskmate/ics.py index 94c3e957..6521179e 100644 --- a/custom_components/taskmate/ics.py +++ b/custom_components/taskmate/ics.py @@ -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 diff --git a/custom_components/taskmate/intents.py b/custom_components/taskmate/intents.py index 8a84ada8..3f9dd336 100644 --- a/custom_components/taskmate/intents.py +++ b/custom_components/taskmate/intents.py @@ -8,6 +8,7 @@ the example sentences in ``custom_sentences//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 diff --git a/custom_components/taskmate/manifest.json b/custom_components/taskmate/manifest.json index 0485e221..42a94c2f 100644 --- a/custom_components/taskmate/manifest.json +++ b/custom_components/taskmate/manifest.json @@ -17,5 +17,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/tempus2016/taskmate/issues", "requirements": [], - "version": "5.1.0" + "version": "5.1.1" } diff --git a/custom_components/taskmate/models.py b/custom_components/taskmate/models.py index 752056cf..0fcefb15 100644 --- a/custom_components/taskmate/models.py +++ b/custom_components/taskmate/models.py @@ -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/ 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) diff --git a/custom_components/taskmate/number.py b/custom_components/taskmate/number.py index cf0194a7..a7e7beda 100644 --- a/custom_components/taskmate/number.py +++ b/custom_components/taskmate/number.py @@ -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) diff --git a/custom_components/taskmate/panel.py b/custom_components/taskmate/panel.py index 400a1631..0d5d1392 100644 --- a/custom_components/taskmate/panel.py +++ b/custom_components/taskmate/panel.py @@ -1,4 +1,5 @@ """Sidebar panel registration for the TaskMate admin UI.""" + from __future__ import annotations import json diff --git a/custom_components/taskmate/photos.py b/custom_components/taskmate/photos.py index 53c8e98e..3fe314c6 100644 --- a/custom_components/taskmate/photos.py +++ b/custom_components/taskmate/photos.py @@ -7,6 +7,7 @@ real HA install. The aiohttp views live in ``http_photos.py``. Photos are stored as ``<32 hex>.`` under ``/taskmate_photos`` and served (auth-gated) at ``/api/taskmate/photo/``. """ + 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: diff --git a/custom_components/taskmate/select.py b/custom_components/taskmate/select.py index 796df45d..098bd80a 100644 --- a/custom_components/taskmate/select.py +++ b/custom_components/taskmate/select.py @@ -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) diff --git a/custom_components/taskmate/sensor.py b/custom_components/taskmate/sensor.py index 47afb788..20bded81 100644 --- a/custom_components/taskmate/sensor.py +++ b/custom_components/taskmate/sensor.py @@ -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 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 { diff --git a/custom_components/taskmate/storage.py b/custom_components/taskmate/storage.py index 5e6c64da..b84a37d2 100644 --- a/custom_components/taskmate/storage.py +++ b/custom_components/taskmate/storage.py @@ -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: diff --git a/custom_components/taskmate/templates.py b/custom_components/taskmate/templates.py index 846d8e22..921b0510 100644 --- a/custom_components/taskmate/templates.py +++ b/custom_components/taskmate/templates.py @@ -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", + }, ], }, ] diff --git a/custom_components/taskmate/todo.py b/custom_components/taskmate/todo.py index b3b612f0..9f245320 100644 --- a/custom_components/taskmate/todo.py +++ b/custom_components/taskmate/todo.py @@ -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() diff --git a/custom_components/taskmate/websocket.py b/custom_components/taskmate/websocket.py index 73b87a15..b7c06ffd 100644 --- a/custom_components/taskmate/websocket.py +++ b/custom_components/taskmate/websocket.py @@ -44,6 +44,7 @@ Settings: All commands require admin. Mutations write through coordinator methods so TaskMate's existing business logic (refunds, cleanup, recompute) runs. """ + from __future__ import annotations import logging @@ -72,102 +73,102 @@ _LOGGER = logging.getLogger(__name__) WS_REGISTERED: Final = "ws_registered" # --- command names --------------------------------------------------------- -WS_GET_STATE: Final = "taskmate/get_state" +WS_GET_STATE: Final = "taskmate/get_state" -WS_ADD_CHILD: Final = "taskmate/add_child" -WS_UPDATE_CHILD: Final = "taskmate/update_child" -WS_REMOVE_CHILD: Final = "taskmate/remove_child" -WS_LIST_HA_USERS: Final = "taskmate/list_ha_users" +WS_ADD_CHILD: Final = "taskmate/add_child" +WS_UPDATE_CHILD: Final = "taskmate/update_child" +WS_REMOVE_CHILD: Final = "taskmate/remove_child" +WS_LIST_HA_USERS: Final = "taskmate/list_ha_users" -WS_ADD_CHORE: Final = "taskmate/add_chore" -WS_UPDATE_CHORE: Final = "taskmate/update_chore" -WS_REMOVE_CHORE: Final = "taskmate/remove_chore" +WS_ADD_CHORE: Final = "taskmate/add_chore" +WS_UPDATE_CHORE: Final = "taskmate/update_chore" +WS_REMOVE_CHORE: Final = "taskmate/remove_chore" -WS_REPORT_FAIRNESS: Final = "taskmate/reports/fairness" -WS_REPORT_FRICTION: Final = "taskmate/reports/friction" -WS_REPORT_PROJECTION: Final = "taskmate/reports/projection" -WS_REPORT_HEALTH: Final = "taskmate/reports/health" -WS_SCHEDULED_LIST: Final = "taskmate/scheduled/list" -WS_SCHEDULED_ADD: Final = "taskmate/scheduled/add" -WS_SCHEDULED_REMOVE: Final = "taskmate/scheduled/remove" +WS_REPORT_FAIRNESS: Final = "taskmate/reports/fairness" +WS_REPORT_FRICTION: Final = "taskmate/reports/friction" +WS_REPORT_PROJECTION: Final = "taskmate/reports/projection" +WS_REPORT_HEALTH: Final = "taskmate/reports/health" +WS_SCHEDULED_LIST: Final = "taskmate/scheduled/list" +WS_SCHEDULED_ADD: Final = "taskmate/scheduled/add" +WS_SCHEDULED_REMOVE: Final = "taskmate/scheduled/remove" -WS_ADD_REWARD: Final = "taskmate/add_reward" -WS_UPDATE_REWARD: Final = "taskmate/update_reward" -WS_REMOVE_REWARD: Final = "taskmate/remove_reward" +WS_ADD_REWARD: Final = "taskmate/add_reward" +WS_UPDATE_REWARD: Final = "taskmate/update_reward" +WS_REMOVE_REWARD: Final = "taskmate/remove_reward" -WS_ADD_PENALTY: Final = "taskmate/add_penalty" -WS_UPDATE_PENALTY: Final = "taskmate/update_penalty" -WS_REMOVE_PENALTY: Final = "taskmate/remove_penalty" -WS_APPLY_PENALTY: Final = "taskmate/apply_penalty" +WS_ADD_PENALTY: Final = "taskmate/add_penalty" +WS_UPDATE_PENALTY: Final = "taskmate/update_penalty" +WS_REMOVE_PENALTY: Final = "taskmate/remove_penalty" +WS_APPLY_PENALTY: Final = "taskmate/apply_penalty" -WS_ADD_BONUS: Final = "taskmate/add_bonus" -WS_UPDATE_BONUS: Final = "taskmate/update_bonus" -WS_REMOVE_BONUS: Final = "taskmate/remove_bonus" -WS_APPLY_BONUS: Final = "taskmate/apply_bonus" +WS_ADD_BONUS: Final = "taskmate/add_bonus" +WS_UPDATE_BONUS: Final = "taskmate/update_bonus" +WS_REMOVE_BONUS: Final = "taskmate/remove_bonus" +WS_APPLY_BONUS: Final = "taskmate/apply_bonus" -WS_CREATE_QUEST: Final = "taskmate/create_quest" -WS_UPDATE_QUEST: Final = "taskmate/update_quest" -WS_DELETE_QUEST: Final = "taskmate/delete_quest" +WS_CREATE_QUEST: Final = "taskmate/create_quest" +WS_UPDATE_QUEST: Final = "taskmate/update_quest" +WS_DELETE_QUEST: Final = "taskmate/delete_quest" WS_UPDATE_AVATAR_CATALOG: Final = "taskmate/update_avatar_catalog" -WS_SET_CHILD_AVATAR: Final = "taskmate/set_child_avatar" +WS_SET_CHILD_AVATAR: Final = "taskmate/set_child_avatar" -WS_CREATE_CHALLENGE: Final = "taskmate/create_challenge" -WS_UPDATE_CHALLENGE: Final = "taskmate/update_challenge" -WS_DELETE_CHALLENGE: Final = "taskmate/delete_challenge" +WS_CREATE_CHALLENGE: Final = "taskmate/create_challenge" +WS_UPDATE_CHALLENGE: Final = "taskmate/update_challenge" +WS_DELETE_CHALLENGE: Final = "taskmate/delete_challenge" -WS_ADD_TASK_GROUP: Final = "taskmate/add_task_group" -WS_UPDATE_TASK_GROUP: Final = "taskmate/update_task_group" -WS_REMOVE_TASK_GROUP: Final = "taskmate/remove_task_group" +WS_ADD_TASK_GROUP: Final = "taskmate/add_task_group" +WS_UPDATE_TASK_GROUP: Final = "taskmate/update_task_group" +WS_REMOVE_TASK_GROUP: Final = "taskmate/remove_task_group" -WS_UPDATE_SETTINGS: Final = "taskmate/update_settings" +WS_UPDATE_SETTINGS: Final = "taskmate/update_settings" # Operational WS_COMPLETE_BONUS_SUBTASK: Final = "taskmate/complete_bonus_subtask" -WS_APPROVE_CHORE: Final = "taskmate/approve_chore" -WS_APPROVE_ALL_CHORES: Final = "taskmate/approve_all_chores" -WS_REJECT_CHORE: Final = "taskmate/reject_chore" -WS_APPROVE_REWARD: Final = "taskmate/approve_reward" -WS_REJECT_REWARD: Final = "taskmate/reject_reward" -WS_SET_CHORE_ORDER: Final = "taskmate/set_chore_order" +WS_APPROVE_CHORE: Final = "taskmate/approve_chore" +WS_APPROVE_ALL_CHORES: Final = "taskmate/approve_all_chores" +WS_REJECT_CHORE: Final = "taskmate/reject_chore" +WS_APPROVE_REWARD: Final = "taskmate/approve_reward" +WS_REJECT_REWARD: Final = "taskmate/reject_reward" +WS_SET_CHORE_ORDER: Final = "taskmate/set_chore_order" WS_SET_GLOBAL_CHORE_ORDER: Final = "taskmate/set_global_chore_order" -WS_ADD_CHORES_BULK: Final = "taskmate/add_chores_bulk" +WS_ADD_CHORES_BULK: Final = "taskmate/add_chores_bulk" WS_PARENT_COMPLETE_CHORE: Final = "taskmate/parent_complete_chore" # Templates -WS_TEMPLATES_LIST: Final = "taskmate/templates/list" -WS_TEMPLATES_GET: Final = "taskmate/templates/get" -WS_TEMPLATES_APPLY: Final = "taskmate/templates/apply" -WS_TEMPLATES_SAVE_FROM: Final = "taskmate/templates/save_from_chores" -WS_TEMPLATES_CREATE: Final = "taskmate/templates/create" -WS_TEMPLATES_UPDATE: Final = "taskmate/templates/update" -WS_TEMPLATES_EXPORT: Final = "taskmate/templates/export" -WS_TEMPLATES_IMPORT: Final = "taskmate/templates/import" -WS_PRINT_CHART: Final = "taskmate/print/weekly_chart" -WS_TEMPLATES_DELETE: Final = "taskmate/templates/delete" +WS_TEMPLATES_LIST: Final = "taskmate/templates/list" +WS_TEMPLATES_GET: Final = "taskmate/templates/get" +WS_TEMPLATES_APPLY: Final = "taskmate/templates/apply" +WS_TEMPLATES_SAVE_FROM: Final = "taskmate/templates/save_from_chores" +WS_TEMPLATES_CREATE: Final = "taskmate/templates/create" +WS_TEMPLATES_UPDATE: Final = "taskmate/templates/update" +WS_TEMPLATES_EXPORT: Final = "taskmate/templates/export" +WS_TEMPLATES_IMPORT: Final = "taskmate/templates/import" +WS_PRINT_CHART: Final = "taskmate/print/weekly_chart" +WS_TEMPLATES_DELETE: Final = "taskmate/templates/delete" # Notifications -WS_NOTIF_GET_STATE: Final = "taskmate/notifications/get_state" -WS_NOTIF_SET_MASTER: Final = "taskmate/notifications/set_master_enabled" -WS_NOTIF_SET_ROUTE: Final = "taskmate/notifications/set_route" -WS_NOTIF_SET_CHILD_NOTIFY: Final = "taskmate/notifications/set_child_notify" -WS_NOTIF_SET_CHILD_QUIET: Final = "taskmate/notifications/set_child_quiet" -WS_NOTIF_UPSERT_PARENT: Final = "taskmate/notifications/upsert_parent" -WS_NOTIF_DELETE_PARENT: Final = "taskmate/notifications/delete_parent" -WS_NOTIF_UPSERT_CUSTOM: Final = "taskmate/notifications/upsert_custom" -WS_NOTIF_DELETE_CUSTOM: Final = "taskmate/notifications/delete_custom" -WS_NOTIF_LIST_NOTIFY: Final = "taskmate/notifications/list_notify_services" -WS_NOTIF_SET_STREAK_CUTOFF: Final = "taskmate/notifications/set_streak_cutoff" -WS_NOTIF_SET_ESCALATION: Final = "taskmate/notifications/set_escalation" -WS_NOTIF_SEND_TEST: Final = "taskmate/notifications/send_test" -WS_NOTIF_SET_NAV_URL: Final = "taskmate/notifications/set_nav_url" +WS_NOTIF_GET_STATE: Final = "taskmate/notifications/get_state" +WS_NOTIF_SET_MASTER: Final = "taskmate/notifications/set_master_enabled" +WS_NOTIF_SET_ROUTE: Final = "taskmate/notifications/set_route" +WS_NOTIF_SET_CHILD_NOTIFY: Final = "taskmate/notifications/set_child_notify" +WS_NOTIF_SET_CHILD_QUIET: Final = "taskmate/notifications/set_child_quiet" +WS_NOTIF_UPSERT_PARENT: Final = "taskmate/notifications/upsert_parent" +WS_NOTIF_DELETE_PARENT: Final = "taskmate/notifications/delete_parent" +WS_NOTIF_UPSERT_CUSTOM: Final = "taskmate/notifications/upsert_custom" +WS_NOTIF_DELETE_CUSTOM: Final = "taskmate/notifications/delete_custom" +WS_NOTIF_LIST_NOTIFY: Final = "taskmate/notifications/list_notify_services" +WS_NOTIF_SET_STREAK_CUTOFF: Final = "taskmate/notifications/set_streak_cutoff" +WS_NOTIF_SET_ESCALATION: Final = "taskmate/notifications/set_escalation" +WS_NOTIF_SEND_TEST: Final = "taskmate/notifications/send_test" +WS_NOTIF_SET_NAV_URL: Final = "taskmate/notifications/set_nav_url" # Calendar ICS feed (FEAT-10) -WS_CAL_GET_URL: Final = "taskmate/calendar/get_ics_url" +WS_CAL_GET_URL: Final = "taskmate/calendar/get_ics_url" WS_CAL_REGEN_TOKEN: Final = "taskmate/calendar/regenerate_ics_token" # Admin audit log -WS_AUDIT_LIST: Final = "taskmate/audit/list" +WS_AUDIT_LIST: Final = "taskmate/audit/list" WS_AUDIT_CLEAR: Final = "taskmate/audit/clear" # Undo / retract @@ -194,10 +195,21 @@ WS_CONFIG_IMPORT: Final = "taskmate/config/import" # Read-only / audit-management commands that should NOT themselves be audited. # Everything else routed through @_admin_only mutates state and is logged. _AUDIT_EXCLUDE: Final = { - WS_GET_STATE, WS_NOTIF_GET_STATE, WS_NOTIF_LIST_NOTIFY, - WS_TEMPLATES_LIST, WS_TEMPLATES_GET, WS_TEMPLATES_EXPORT, WS_PRINT_CHART, - WS_AUDIT_LIST, WS_AUDIT_CLEAR, - WS_CONFIG_EXPORT, WS_SCHEDULED_LIST, WS_REPORT_FAIRNESS, WS_REPORT_FRICTION, WS_REPORT_PROJECTION, WS_REPORT_HEALTH, + WS_GET_STATE, + WS_NOTIF_GET_STATE, + WS_NOTIF_LIST_NOTIFY, + WS_TEMPLATES_LIST, + WS_TEMPLATES_GET, + WS_TEMPLATES_EXPORT, + WS_PRINT_CHART, + WS_AUDIT_LIST, + WS_AUDIT_CLEAR, + WS_CONFIG_EXPORT, + WS_SCHEDULED_LIST, + WS_REPORT_FAIRNESS, + WS_REPORT_FRICTION, + WS_REPORT_PROJECTION, + WS_REPORT_HEALTH, } @@ -216,9 +228,18 @@ def _audit_target(coordinator, msg: dict) -> str: obj = getter(val) return getattr(obj, "name", None) or str(val) for key in ( - "penalty_id", "bonus_id", "badge_id", "group_id", "template_id", - "completion_id", "claim_id", "parent_id", "custom_id", - "awarded_badge_id", "type_id", "transaction_id", + "penalty_id", + "bonus_id", + "badge_id", + "group_id", + "template_id", + "completion_id", + "claim_id", + "parent_id", + "custom_id", + "awarded_badge_id", + "type_id", + "transaction_id", ): if msg.get(key): return str(msg[key]) @@ -234,6 +255,7 @@ def _get_coordinator(hass: HomeAssistant) -> TaskMateCoordinator | None: def _admin_only(handler): """Enforce admin + coordinator availability + uniform error reporting.""" + @wraps(handler) async def wrapper(hass, connection, msg): if not connection.user.is_admin: @@ -266,6 +288,7 @@ def _admin_only(handler): ) except Exception: # noqa: BLE001 _LOGGER.debug("audit record failed for %s", msg.get("type"), exc_info=True) + return wrapper @@ -273,6 +296,7 @@ def _admin_only(handler): # Validators / coercers # --------------------------------------------------------------------------- + def _opt_str(v: Any) -> str: """Coerce optional string field to stripped str (or empty).""" if v is None: @@ -284,6 +308,7 @@ def _opt_str(v: Any) -> str: # State snapshot # --------------------------------------------------------------------------- + def _build_state_snapshot(coordinator: TaskMateCoordinator) -> dict[str, Any]: data = coordinator.storage.data completions = list(data.get("completions", [])) @@ -292,8 +317,7 @@ def _build_state_snapshot(coordinator: TaskMateCoordinator) -> dict[str, Any]: # bearer token, so the auth-gated serve view would 401. Copy each dict so # the expiring signed URL is never written back into storage. completions = [ - {**c, "photo_url": photos.sign_photo_url(coordinator.hass, c["photo_url"])} - if c.get("photo_url") else c + {**c, "photo_url": photos.sign_photo_url(coordinator.hass, c["photo_url"])} if c.get("photo_url") else c for c in completions ] reward_claims = list(data.get("reward_claims", [])) @@ -310,47 +334,46 @@ def _build_state_snapshot(coordinator: TaskMateCoordinator) -> dict[str, Any]: return { "version": "2", - "children": list(data.get("children", [])), + "children": list(data.get("children", [])), # Sign picture URLs so the panel's loads — browsers do not # send the bearer token on image requests, so a bare URL 401s. "chores": [ - {**c, "image_url": images.sign_image_url(coordinator.hass, c["image_url"])} - if c.get("image_url") else c + {**c, "image_url": images.sign_image_url(coordinator.hass, c["image_url"])} if c.get("image_url") else c for c in data.get("chores", []) ], "chore_display_order": list(data.get("chore_display_order", [])), "scheduled_changes": list(data.get("scheduled_changes", [])), - "rewards": list(data.get("rewards", [])), - "penalties": list(data.get("penalties", [])), - "bonuses": list(data.get("bonuses", [])), - "task_groups": list(data.get("task_groups", [])), - "quests": list(data.get("quests", [])), - "quest_progress": dict(data.get("quest_progress", {}) or {}), - "avatar_catalog": coordinator.avatar_catalog(), - "challenges": list(data.get("challenges", [])), + "rewards": list(data.get("rewards", [])), + "penalties": list(data.get("penalties", [])), + "bonuses": list(data.get("bonuses", [])), + "task_groups": list(data.get("task_groups", [])), + "quests": list(data.get("quests", [])), + "quest_progress": dict(data.get("quest_progress", {}) or {}), + "avatar_catalog": coordinator.avatar_catalog(), + "challenges": list(data.get("challenges", [])), "pool_allocations": list(data.get("pool_allocations", [])), - "timed_sessions": list(data.get("timed_sessions", [])), - "templates": coordinator.get_all_templates(), + "timed_sessions": list(data.get("timed_sessions", [])), + "templates": coordinator.get_all_templates(), # Operational state — used by the panel's Activity tab + approval banner - "completions": completions, # all (panel slices for display) - "pending_completions": [c for c in completions if not c.get("approved")], - "reward_claims": reward_claims, + "completions": completions, # all (panel slices for display) + "pending_completions": [c for c in completions if not c.get("approved")], + "reward_claims": reward_claims, "pending_reward_claims": [c for c in reward_claims if not c.get("approved")], - "mandatory_misses": coordinator.mandatory_misses_state(), # missed mandatory chores awaiting review (#532) - "points_transactions": transactions[-100:], # most recent 100 for audit log - "badges": list(data.get("badges", [])), + "mandatory_misses": coordinator.mandatory_misses_state(), # missed mandatory chores awaiting review (#532) + "points_transactions": transactions[-100:], # most recent 100 for audit log + "badges": list(data.get("badges", [])), "awarded_badges": list(data.get("awarded_badges", [])), - "audit_log": coordinator.storage.get_audit_log()[:100], # newest 100 for the panel + "audit_log": coordinator.storage.get_audit_log()[:100], # newest 100 for the panel "swap_requests": [r for r in coordinator.storage.get_swap_requests() if r.get("status") == "pending"], "allowance_payouts": list(reversed(coordinator.storage.get_allowance_payouts()))[:50], # newest first (FEAT-3) "settings": { - "points_name": data.get("points_name", "Stars"), - "points_icon": data.get("points_icon", "mdi:star"), - "card_design": "classic", + "points_name": data.get("points_name", "Stars"), + "points_icon": data.get("points_icon", "mdi:star"), + "card_design": "classic", # Difficulty multiplier defaults; overridden by stored values below. - "difficulty_multiplier_easy": 0.5, + "difficulty_multiplier_easy": 0.5, "difficulty_multiplier_medium": 1.0, - "difficulty_multiplier_hard": 2.0, + "difficulty_multiplier_hard": 2.0, **(data.get("settings", {}) or {}), }, "parent_completable": parent_completable, @@ -368,16 +391,19 @@ async def _ws_get_state(hass, connection, msg, coordinator): # Children # --------------------------------------------------------------------------- -@websocket_api.websocket_command({ - vol.Required("type"): WS_ADD_CHILD, - vol.Required("name"): vol.All(str, vol.Length(min=1, max=120)), - vol.Optional("avatar", default="mdi:account-circle"): str, - vol.Optional("availability_entity", default=""): str, - vol.Optional("availability_inverted", default=False): bool, - vol.Optional("unavailability_entity", default=""): str, - vol.Optional("pause_streak_when_unavailable", default=False): bool, - vol.Optional("linked_user_id", default=""): str, -}) + +@websocket_api.websocket_command( + { + vol.Required("type"): WS_ADD_CHILD, + vol.Required("name"): vol.All(str, vol.Length(min=1, max=120)), + vol.Optional("avatar", default="mdi:account-circle"): str, + vol.Optional("availability_entity", default=""): str, + vol.Optional("availability_inverted", default=False): bool, + vol.Optional("unavailability_entity", default=""): str, + vol.Optional("pause_streak_when_unavailable", default=False): bool, + vol.Optional("linked_user_id", default=""): str, + } +) @websocket_api.async_response @_admin_only async def _ws_add_child(hass, connection, msg, coordinator): @@ -393,19 +419,21 @@ async def _ws_add_child(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": child.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_UPDATE_CHILD, - vol.Required("child_id"): str, - vol.Optional("name"): vol.All(str, vol.Length(min=1, max=120)), - vol.Optional("avatar"): str, - vol.Optional("availability_entity"): str, - vol.Optional("availability_inverted"): bool, - vol.Optional("unavailability_entity"): str, - vol.Optional("pause_streak_when_unavailable"): bool, - vol.Optional("linked_user_id"): str, - vol.Optional("is_guest"): bool, - vol.Optional("guest_expires_on"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_UPDATE_CHILD, + vol.Required("child_id"): str, + vol.Optional("name"): vol.All(str, vol.Length(min=1, max=120)), + vol.Optional("avatar"): str, + vol.Optional("availability_entity"): str, + vol.Optional("availability_inverted"): bool, + vol.Optional("unavailability_entity"): str, + vol.Optional("pause_streak_when_unavailable"): bool, + vol.Optional("linked_user_id"): str, + vol.Optional("is_guest"): bool, + vol.Optional("guest_expires_on"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_update_child(hass, connection, msg, coordinator): @@ -444,10 +472,12 @@ async def _ws_update_child(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": existing.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_REMOVE_CHILD, - vol.Required("child_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REMOVE_CHILD, + vol.Required("child_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_remove_child(hass, connection, msg, coordinator): @@ -458,9 +488,11 @@ async def _ws_remove_child(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": msg["child_id"]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_LIST_HA_USERS, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_LIST_HA_USERS, + } +) @websocket_api.async_response @_admin_only async def _ws_list_ha_users(hass, connection, msg, coordinator): @@ -483,6 +515,7 @@ async def _ws_list_ha_users(hass, connection, msg, coordinator): # Chores # --------------------------------------------------------------------------- + def _image_url_or_blank(value): """Accept a blank string (clears the picture) or one of our image URLs. @@ -500,21 +533,52 @@ def _image_url_or_blank(value): # assignment_current_child_id, publish_calendar_published_dates, etc.) is # coordinator-managed runtime state and intentionally not exposed. _CHORE_EDITABLE_FIELDS = { - "name", "description", "points", "assigned_to", "depends_on", "requires_approval", - "time_category", "claim_allowance_minutes", "daily_limit", "completion_sound", - "icon", "image_url", "difficulty", - "schedule_mode", "due_days", "recurrence", "recurrence_day", - "recurrence_start", "first_occurrence_mode", "visibility_entity", - "visibility_state", "visibility_operator", - "weather_entity", "weather_block_conditions", "weather_temp_min", - "weather_temp_max", "weather_wind_max", - "deadline_at", "speed_bonus_points", - "enabled", "expires_on", - "due_time", "early_bonus", "late_penalty", "require_photo", - "mandatory", "mandatory_penalty_points", - "assignment_mode", "assignment_rotation_anchor", "require_availability", - "publish_calendar_entities", "bonus_subtasks", - "task_type", "timed_rate_points", "timed_rate_minutes", "timed_max_daily_minutes", + "name", + "description", + "points", + "assigned_to", + "depends_on", + "requires_approval", + "time_category", + "claim_allowance_minutes", + "daily_limit", + "completion_sound", + "icon", + "image_url", + "difficulty", + "schedule_mode", + "due_days", + "recurrence", + "recurrence_day", + "recurrence_start", + "first_occurrence_mode", + "visibility_entity", + "visibility_state", + "visibility_operator", + "weather_entity", + "weather_block_conditions", + "weather_temp_min", + "weather_temp_max", + "weather_wind_max", + "deadline_at", + "speed_bonus_points", + "enabled", + "expires_on", + "due_time", + "early_bonus", + "late_penalty", + "require_photo", + "mandatory", + "mandatory_penalty_points", + "assignment_mode", + "assignment_rotation_anchor", + "require_availability", + "publish_calendar_entities", + "bonus_subtasks", + "task_type", + "timed_rate_points", + "timed_rate_minutes", + "timed_max_daily_minutes", } @@ -560,16 +624,20 @@ def _chore_payload_schema(*, require_name: bool): vol.Optional("mandatory"): bool, vol.Optional("mandatory_penalty_points"): vol.All(int, vol.Range(min=0)), vol.Optional("require_photo"): bool, - vol.Optional("assignment_mode"): vol.In(["everyone", "alternating", "random", "balanced", "first_come", "unassigned"]), + vol.Optional("assignment_mode"): vol.In( + ["everyone", "alternating", "random", "balanced", "first_come", "unassigned"] + ), vol.Optional("assignment_rotation_anchor"): str, vol.Optional("require_availability"): bool, vol.Optional("publish_calendar_entities"): [str], - vol.Optional("bonus_subtasks"): [{ - vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Optional("points"): vol.All(int, vol.Range(min=0)), - vol.Optional("description"): str, - vol.Optional("id"): str, - }], + vol.Optional("bonus_subtasks"): [ + { + vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("points"): vol.All(int, vol.Range(min=0)), + vol.Optional("description"): str, + vol.Optional("id"): str, + } + ], vol.Optional("task_type"): vol.In(["standard", "timed"]), vol.Optional("timed_rate_points"): vol.All(int, vol.Range(min=1)), vol.Optional("timed_rate_minutes"): vol.All(int, vol.Range(min=1)), @@ -592,11 +660,13 @@ async def _maybe_apply_manual_start(coordinator, chore_id: str, child_id: str | _LOGGER.debug("manual start ignored for %s: %s", chore_id, err) -@websocket_api.websocket_command({ - vol.Required("type"): WS_ADD_CHORE, - vol.Optional("manual_start_child_id"): vol.Any(str, None), - **_chore_payload_schema(require_name=True), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_ADD_CHORE, + vol.Optional("manual_start_child_id"): vol.Any(str, None), + **_chore_payload_schema(require_name=True), + } +) @websocket_api.async_response @_admin_only async def _ws_add_chore(hass, connection, msg, coordinator): @@ -613,9 +683,16 @@ async def _ws_add_chore(hass, connection, msg, coordinator): schedule_mode=msg.get("schedule_mode", "specific_days"), ) extra_fields = (set(msg.keys()) & _CHORE_EDITABLE_FIELDS) - { - "name", "points", "description", "assigned_to", "requires_approval", - "time_category", "claim_allowance_minutes", "daily_limit", - "completion_sound", "schedule_mode", + "name", + "points", + "description", + "assigned_to", + "requires_approval", + "time_category", + "claim_allowance_minutes", + "daily_limit", + "completion_sound", + "schedule_mode", } if extra_fields: for f in extra_fields: @@ -628,12 +705,14 @@ async def _ws_add_chore(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": chore.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_UPDATE_CHORE, - vol.Required("chore_id"): str, - vol.Optional("manual_start_child_id"): vol.Any(str, None), - **_chore_payload_schema(require_name=False), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_UPDATE_CHORE, + vol.Required("chore_id"): str, + vol.Optional("manual_start_child_id"): vol.Any(str, None), + **_chore_payload_schema(require_name=False), + } +) @websocket_api.async_response @_admin_only async def _ws_update_chore(hass, connection, msg, coordinator): @@ -656,10 +735,12 @@ async def _ws_update_chore(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": existing.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_REMOVE_CHORE, - vol.Required("chore_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REMOVE_CHORE, + vol.Required("chore_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_remove_chore(hass, connection, msg, coordinator): @@ -670,55 +751,65 @@ async def _ws_remove_chore(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": msg["chore_id"]}) - # --------------------------------------------------------------------------- # Insight reports (#679) # --------------------------------------------------------------------------- -@websocket_api.websocket_command({ - vol.Required("type"): WS_REPORT_FAIRNESS, - vol.Optional("days"): vol.All(int, vol.Range(min=1, max=90)), -}) + +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REPORT_FAIRNESS, + vol.Optional("days"): vol.All(int, vol.Range(min=1, max=90)), + } +) @websocket_api.async_response @_admin_only async def _ws_report_fairness(hass, connection, msg, coordinator): connection.send_result(msg["id"], coordinator.fairness_report(msg.get("days"))) -@websocket_api.websocket_command({ - vol.Required("type"): WS_REPORT_FRICTION, - vol.Optional("days"): vol.All(int, vol.Range(min=1, max=90)), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REPORT_FRICTION, + vol.Optional("days"): vol.All(int, vol.Range(min=1, max=90)), + } +) @websocket_api.async_response @_admin_only async def _ws_report_friction(hass, connection, msg, coordinator): connection.send_result(msg["id"], coordinator.friction_report(msg.get("days"))) -@websocket_api.websocket_command({ - vol.Required("type"): WS_REPORT_PROJECTION, - vol.Optional("days"): vol.All(int, vol.Range(min=1, max=28)), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REPORT_PROJECTION, + vol.Optional("days"): vol.All(int, vol.Range(min=1, max=28)), + } +) @websocket_api.async_response @_admin_only async def _ws_report_projection(hass, connection, msg, coordinator): connection.send_result(msg["id"], coordinator.projection_report(msg.get("days"))) -@websocket_api.websocket_command({ - vol.Required("type"): WS_TEMPLATES_EXPORT, - vol.Optional("template_ids"): [str], -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_TEMPLATES_EXPORT, + vol.Optional("template_ids"): [str], + } +) @websocket_api.async_response @_admin_only async def _ws_templates_export(hass, connection, msg, coordinator): connection.send_result(msg["id"], coordinator.export_templates(msg.get("template_ids"))) -@websocket_api.websocket_command({ - vol.Required("type"): WS_TEMPLATES_IMPORT, - vol.Required("pack"): dict, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_TEMPLATES_IMPORT, + vol.Required("pack"): dict, + } +) @websocket_api.async_response @_admin_only async def _ws_templates_import(hass, connection, msg, coordinator): @@ -730,12 +821,14 @@ async def _ws_templates_import(hass, connection, msg, coordinator): connection.send_result(msg["id"], result) -@websocket_api.websocket_command({ - vol.Required("type"): WS_PRINT_CHART, - vol.Optional("orientation", default="portrait"): vol.In(["portrait", "landscape"]), - vol.Optional("week_start"): str, - vol.Optional("title"): vol.All(str, vol.Length(max=80)), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_PRINT_CHART, + vol.Optional("orientation", default="portrait"): vol.In(["portrait", "landscape"]), + vol.Optional("week_start"): str, + vol.Optional("title"): vol.All(str, vol.Length(max=80)), + } +) @websocket_api.async_response @_admin_only async def _ws_print_chart(hass, connection, msg, coordinator): @@ -775,10 +868,13 @@ async def _ws_report_health(hass, connection, msg, coordinator): # Scheduled config changes (#675) # --------------------------------------------------------------------------- -@websocket_api.websocket_command({ - vol.Required("type"): WS_SCHEDULED_LIST, - vol.Optional("chore_id"): str, -}) + +@websocket_api.websocket_command( + { + vol.Required("type"): WS_SCHEDULED_LIST, + vol.Optional("chore_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_scheduled_list(hass, connection, msg, coordinator): @@ -786,13 +882,15 @@ async def _ws_scheduled_list(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"changes": [c.to_dict() for c in changes]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_SCHEDULED_ADD, - vol.Required("chore_id"): str, - vol.Required("apply_on"): str, - vol.Required("changes"): dict, - vol.Optional("note", default=""): vol.All(str, vol.Length(max=200)), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_SCHEDULED_ADD, + vol.Required("chore_id"): str, + vol.Required("apply_on"): str, + vol.Required("changes"): dict, + vol.Optional("note", default=""): vol.All(str, vol.Length(max=200)), + } +) @websocket_api.async_response @_admin_only async def _ws_scheduled_add(hass, connection, msg, coordinator): @@ -809,10 +907,12 @@ async def _ws_scheduled_add(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": change.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_SCHEDULED_REMOVE, - vol.Required("change_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_SCHEDULED_REMOVE, + vol.Required("change_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_scheduled_remove(hass, connection, msg, coordinator): @@ -828,10 +928,22 @@ async def _ws_scheduled_remove(hass, connection, msg, coordinator): # Rewards # --------------------------------------------------------------------------- -_REWARD_FIELDS = {"name", "cost", "description", "icon", "assigned_to", - "is_jackpot", "pool_enabled", "quantity", "expires_at", - "restock_enabled", "restock_amount", "restock_period", - "unlock_entity", "unlock_minutes"} +_REWARD_FIELDS = { + "name", + "cost", + "description", + "icon", + "assigned_to", + "is_jackpot", + "pool_enabled", + "quantity", + "expires_at", + "restock_enabled", + "restock_amount", + "restock_period", + "unlock_entity", + "unlock_minutes", +} def _reward_payload_schema(*, require_name: bool): @@ -855,10 +967,12 @@ def _reward_payload_schema(*, require_name: bool): } -@websocket_api.websocket_command({ - vol.Required("type"): WS_ADD_REWARD, - **_reward_payload_schema(require_name=True), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_ADD_REWARD, + **_reward_payload_schema(require_name=True), + } +) @websocket_api.async_response @_admin_only async def _ws_add_reward(hass, connection, msg, coordinator): @@ -866,7 +980,8 @@ async def _ws_add_reward(hass, connection, msg, coordinator): # allowlist, at save time, with a message the panel can show. try: unlock_entity, unlock_minutes = coordinator.validate_unlock( - msg.get("unlock_entity", ""), msg.get("unlock_minutes", 0), + msg.get("unlock_entity", ""), + msg.get("unlock_minutes", 0), ) except ValueError as err: connection.send_error(msg["id"], "invalid_format", str(err)) @@ -896,11 +1011,13 @@ async def _ws_add_reward(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": reward.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_UPDATE_REWARD, - vol.Required("reward_id"): str, - **_reward_payload_schema(require_name=False), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_UPDATE_REWARD, + vol.Required("reward_id"): str, + **_reward_payload_schema(require_name=False), + } +) @websocket_api.async_response @_admin_only async def _ws_update_reward(hass, connection, msg, coordinator): @@ -931,10 +1048,12 @@ async def _ws_update_reward(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": existing.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_REMOVE_REWARD, - vol.Required("reward_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REMOVE_REWARD, + vol.Required("reward_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_remove_reward(hass, connection, msg, coordinator): @@ -949,17 +1068,20 @@ async def _ws_remove_reward(hass, connection, msg, coordinator): # Quests (chore chains) # --------------------------------------------------------------------------- -@websocket_api.websocket_command({ - vol.Required("type"): WS_CREATE_QUEST, - vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Optional("description", default=""): str, - vol.Optional("icon", default="mdi:map-marker-path"): str, - vol.Required("steps"): vol.All([str], vol.Length(min=1)), - vol.Optional("bonus_points", default=25): vol.All(int, vol.Range(min=0, max=1000000)), - vol.Optional("assigned_to", default=[]): [str], - vol.Optional("repeatable", default=False): bool, - vol.Optional("active", default=True): bool, -}) + +@websocket_api.websocket_command( + { + vol.Required("type"): WS_CREATE_QUEST, + vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("description", default=""): str, + vol.Optional("icon", default="mdi:map-marker-path"): str, + vol.Required("steps"): vol.All([str], vol.Length(min=1)), + vol.Optional("bonus_points", default=25): vol.All(int, vol.Range(min=0, max=1000000)), + vol.Optional("assigned_to", default=[]): [str], + vol.Optional("repeatable", default=False): bool, + vol.Optional("active", default=True): bool, + } +) @websocket_api.async_response @_admin_only async def _ws_create_quest(hass, connection, msg, coordinator): @@ -976,18 +1098,20 @@ async def _ws_create_quest(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": quest_id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_UPDATE_QUEST, - vol.Required("quest_id"): str, - vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Optional("description"): str, - vol.Optional("icon"): str, - vol.Optional("steps"): vol.All([str], vol.Length(min=1)), - vol.Optional("bonus_points"): vol.All(int, vol.Range(min=0, max=1000000)), - vol.Optional("assigned_to"): [str], - vol.Optional("repeatable"): bool, - vol.Optional("active"): bool, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_UPDATE_QUEST, + vol.Required("quest_id"): str, + vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("description"): str, + vol.Optional("icon"): str, + vol.Optional("steps"): vol.All([str], vol.Length(min=1)), + vol.Optional("bonus_points"): vol.All(int, vol.Range(min=0, max=1000000)), + vol.Optional("assigned_to"): [str], + vol.Optional("repeatable"): bool, + vol.Optional("active"): bool, + } +) @websocket_api.async_response @_admin_only async def _ws_update_quest(hass, connection, msg, coordinator): @@ -1002,10 +1126,12 @@ async def _ws_update_quest(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": msg["quest_id"]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_DELETE_QUEST, - vol.Required("quest_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_DELETE_QUEST, + vol.Required("quest_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_delete_quest(hass, connection, msg, coordinator): @@ -1021,16 +1147,21 @@ async def _ws_delete_quest(hass, connection, msg, coordinator): # Avatar unlockables # --------------------------------------------------------------------------- -@websocket_api.websocket_command({ - vol.Required("type"): WS_UPDATE_AVATAR_CATALOG, - vol.Required("catalog"): [{ - vol.Optional("id"): str, - vol.Optional("label"): str, - vol.Required("icon"): str, - vol.Optional("unlock_type"): vol.In(["free", "level", "points", "streak"]), - vol.Optional("unlock_value"): vol.All(vol.Coerce(int), vol.Range(min=0, max=1000000)), - }], -}) + +@websocket_api.websocket_command( + { + vol.Required("type"): WS_UPDATE_AVATAR_CATALOG, + vol.Required("catalog"): [ + { + vol.Optional("id"): str, + vol.Optional("label"): str, + vol.Required("icon"): str, + vol.Optional("unlock_type"): vol.In(["free", "level", "points", "streak"]), + vol.Optional("unlock_value"): vol.All(vol.Coerce(int), vol.Range(min=0, max=1000000)), + } + ], + } +) @websocket_api.async_response @_admin_only async def _ws_update_avatar_catalog(hass, connection, msg, coordinator): @@ -1038,11 +1169,13 @@ async def _ws_update_avatar_catalog(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"count": len(msg["catalog"])}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_SET_CHILD_AVATAR, - vol.Required("child_id"): str, - vol.Required("icon"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_SET_CHILD_AVATAR, + vol.Required("child_id"): str, + vol.Required("icon"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_set_child_avatar(hass, connection, msg, coordinator): @@ -1059,18 +1192,21 @@ async def _ws_set_child_avatar(hass, connection, msg, coordinator): # Challenges (daily / weekly) # --------------------------------------------------------------------------- -@websocket_api.websocket_command({ - vol.Required("type"): WS_CREATE_CHALLENGE, - vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Optional("description", default=""): str, - vol.Optional("icon", default="mdi:trophy-outline"): str, - vol.Optional("scope", default="daily"): vol.In(["daily", "weekly"]), - vol.Optional("metric", default="chores"): vol.In(["chores", "points"]), - vol.Required("target"): vol.All(int, vol.Range(min=1, max=1000000)), - vol.Optional("bonus_points", default=15): vol.All(int, vol.Range(min=0, max=1000000)), - vol.Optional("assigned_to", default=[]): [str], - vol.Optional("active", default=True): bool, -}) + +@websocket_api.websocket_command( + { + vol.Required("type"): WS_CREATE_CHALLENGE, + vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("description", default=""): str, + vol.Optional("icon", default="mdi:trophy-outline"): str, + vol.Optional("scope", default="daily"): vol.In(["daily", "weekly"]), + vol.Optional("metric", default="chores"): vol.In(["chores", "points"]), + vol.Required("target"): vol.All(int, vol.Range(min=1, max=1000000)), + vol.Optional("bonus_points", default=15): vol.All(int, vol.Range(min=0, max=1000000)), + vol.Optional("assigned_to", default=[]): [str], + vol.Optional("active", default=True): bool, + } +) @websocket_api.async_response @_admin_only async def _ws_create_challenge(hass, connection, msg, coordinator): @@ -1088,19 +1224,21 @@ async def _ws_create_challenge(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": challenge_id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_UPDATE_CHALLENGE, - vol.Required("challenge_id"): str, - vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Optional("description"): str, - vol.Optional("icon"): str, - vol.Optional("scope"): vol.In(["daily", "weekly"]), - vol.Optional("metric"): vol.In(["chores", "points"]), - vol.Optional("target"): vol.All(int, vol.Range(min=1, max=1000000)), - vol.Optional("bonus_points"): vol.All(int, vol.Range(min=0, max=1000000)), - vol.Optional("assigned_to"): [str], - vol.Optional("active"): bool, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_UPDATE_CHALLENGE, + vol.Required("challenge_id"): str, + vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("description"): str, + vol.Optional("icon"): str, + vol.Optional("scope"): vol.In(["daily", "weekly"]), + vol.Optional("metric"): vol.In(["chores", "points"]), + vol.Optional("target"): vol.All(int, vol.Range(min=1, max=1000000)), + vol.Optional("bonus_points"): vol.All(int, vol.Range(min=0, max=1000000)), + vol.Optional("assigned_to"): [str], + vol.Optional("active"): bool, + } +) @websocket_api.async_response @_admin_only async def _ws_update_challenge(hass, connection, msg, coordinator): @@ -1115,10 +1253,12 @@ async def _ws_update_challenge(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": msg["challenge_id"]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_DELETE_CHALLENGE, - vol.Required("challenge_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_DELETE_CHALLENGE, + vol.Required("challenge_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_delete_challenge(hass, connection, msg, coordinator): @@ -1134,14 +1274,17 @@ async def _ws_delete_challenge(hass, connection, msg, coordinator): # Penalties # --------------------------------------------------------------------------- -@websocket_api.websocket_command({ - vol.Required("type"): WS_ADD_PENALTY, - vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Required("points"): vol.All(int, vol.Range(min=1)), - vol.Optional("description", default=""): str, - vol.Optional("icon", default="mdi:alert-circle-outline"): str, - vol.Optional("assigned_to", default=[]): [str], -}) + +@websocket_api.websocket_command( + { + vol.Required("type"): WS_ADD_PENALTY, + vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Required("points"): vol.All(int, vol.Range(min=1)), + vol.Optional("description", default=""): str, + vol.Optional("icon", default="mdi:alert-circle-outline"): str, + vol.Optional("assigned_to", default=[]): [str], + } +) @websocket_api.async_response @_admin_only async def _ws_add_penalty(hass, connection, msg, coordinator): @@ -1155,15 +1298,17 @@ async def _ws_add_penalty(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": pen.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_UPDATE_PENALTY, - vol.Required("penalty_id"): str, - vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Optional("points"): vol.All(int, vol.Range(min=1)), - vol.Optional("description"): str, - vol.Optional("icon"): str, - vol.Optional("assigned_to"): [str], -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_UPDATE_PENALTY, + vol.Required("penalty_id"): str, + vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("points"): vol.All(int, vol.Range(min=1)), + vol.Optional("description"): str, + vol.Optional("icon"): str, + vol.Optional("assigned_to"): [str], + } +) @websocket_api.async_response @_admin_only async def _ws_update_penalty(hass, connection, msg, coordinator): @@ -1183,10 +1328,12 @@ async def _ws_update_penalty(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": existing.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_REMOVE_PENALTY, - vol.Required("penalty_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REMOVE_PENALTY, + vol.Required("penalty_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_remove_penalty(hass, connection, msg, coordinator): @@ -1197,17 +1344,17 @@ async def _ws_remove_penalty(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": msg["penalty_id"]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_APPLY_PENALTY, - vol.Required("penalty_id"): str, - vol.Required("child_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_APPLY_PENALTY, + vol.Required("penalty_id"): str, + vol.Required("child_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_apply_penalty(hass, connection, msg, coordinator): - await coordinator.async_apply_penalty( - penalty_id=msg["penalty_id"], child_id=msg["child_id"] - ) + await coordinator.async_apply_penalty(penalty_id=msg["penalty_id"], child_id=msg["child_id"]) connection.send_result(msg["id"], {"penalty_id": msg["penalty_id"], "child_id": msg["child_id"]}) @@ -1215,14 +1362,17 @@ async def _ws_apply_penalty(hass, connection, msg, coordinator): # Bonuses (mirror of penalties) # --------------------------------------------------------------------------- -@websocket_api.websocket_command({ - vol.Required("type"): WS_ADD_BONUS, - vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Required("points"): vol.All(int, vol.Range(min=1)), - vol.Optional("description", default=""): str, - vol.Optional("icon", default="mdi:star-circle-outline"): str, - vol.Optional("assigned_to", default=[]): [str], -}) + +@websocket_api.websocket_command( + { + vol.Required("type"): WS_ADD_BONUS, + vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Required("points"): vol.All(int, vol.Range(min=1)), + vol.Optional("description", default=""): str, + vol.Optional("icon", default="mdi:star-circle-outline"): str, + vol.Optional("assigned_to", default=[]): [str], + } +) @websocket_api.async_response @_admin_only async def _ws_add_bonus(hass, connection, msg, coordinator): @@ -1236,15 +1386,17 @@ async def _ws_add_bonus(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": b.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_UPDATE_BONUS, - vol.Required("bonus_id"): str, - vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Optional("points"): vol.All(int, vol.Range(min=1)), - vol.Optional("description"): str, - vol.Optional("icon"): str, - vol.Optional("assigned_to"): [str], -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_UPDATE_BONUS, + vol.Required("bonus_id"): str, + vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("points"): vol.All(int, vol.Range(min=1)), + vol.Optional("description"): str, + vol.Optional("icon"): str, + vol.Optional("assigned_to"): [str], + } +) @websocket_api.async_response @_admin_only async def _ws_update_bonus(hass, connection, msg, coordinator): @@ -1264,10 +1416,12 @@ async def _ws_update_bonus(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": existing.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_REMOVE_BONUS, - vol.Required("bonus_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REMOVE_BONUS, + vol.Required("bonus_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_remove_bonus(hass, connection, msg, coordinator): @@ -1278,11 +1432,13 @@ async def _ws_remove_bonus(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": msg["bonus_id"]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_APPLY_BONUS, - vol.Required("bonus_id"): str, - vol.Required("child_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_APPLY_BONUS, + vol.Required("bonus_id"): str, + vol.Required("child_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_apply_bonus(hass, connection, msg, coordinator): @@ -1294,12 +1450,15 @@ async def _ws_apply_bonus(hass, connection, msg, coordinator): # Task groups # --------------------------------------------------------------------------- -@websocket_api.websocket_command({ - vol.Required("type"): WS_ADD_TASK_GROUP, - vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Required("policy"): vol.In(["sticky", "spread"]), - vol.Optional("chore_ids", default=[]): [str], -}) + +@websocket_api.websocket_command( + { + vol.Required("type"): WS_ADD_TASK_GROUP, + vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Required("policy"): vol.In(["sticky", "spread"]), + vol.Optional("chore_ids", default=[]): [str], + } +) @websocket_api.async_response @_admin_only async def _ws_add_task_group(hass, connection, msg, coordinator): @@ -1311,13 +1470,15 @@ async def _ws_add_task_group(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": g.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_UPDATE_TASK_GROUP, - vol.Required("group_id"): str, - vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Optional("policy"): vol.In(["sticky", "spread"]), - vol.Optional("chore_ids"): [str], -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_UPDATE_TASK_GROUP, + vol.Required("group_id"): str, + vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("policy"): vol.In(["sticky", "spread"]), + vol.Optional("chore_ids"): [str], + } +) @websocket_api.async_response @_admin_only async def _ws_update_task_group(hass, connection, msg, coordinator): @@ -1330,10 +1491,12 @@ async def _ws_update_task_group(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": g.id if g else msg["group_id"]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_REMOVE_TASK_GROUP, - vol.Required("group_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REMOVE_TASK_GROUP, + vol.Required("group_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_remove_task_group(hass, connection, msg, coordinator): @@ -1354,30 +1517,67 @@ _TOP_LEVEL_SETTINGS = {"points_name", "points_icon"} _ALLOWED_CARD_DESIGNS = {"classic", "playroom", "console", "cleanpro", "accessible"} # Settings stored under storage._data["settings"][key] _SUBKEY_SETTINGS = { - "history_days", "streak_reset_mode", "card_design", - "weekend_multiplier", "streak_milestones_enabled", "perfect_week_enabled", - "perfect_week_bonus", "streak_milestones", "quick_point_amounts", - "streak_requires_all_chores", "perfect_week_requires_all_chores", - "difficulty_multiplier_easy", "difficulty_multiplier_medium", "difficulty_multiplier_hard", - "unlock_allowlist", "parent_routing", - "read_aloud_media_player", "read_aloud_tts_entity", "read_aloud_template", - "read_aloud_one_template", "read_aloud_done_template", "read_aloud_joiner", - "roulette_enabled", "roulette_multiplier", "roulette_daily_spins", - "surprise_bonus_enabled", "surprise_bonus_chance", "surprise_bonus_min", "surprise_bonus_max", - "points_decay_enabled", "points_decay_period", "points_decay_percent", + "history_days", + "streak_reset_mode", + "card_design", + "weekend_multiplier", + "streak_milestones_enabled", + "perfect_week_enabled", + "perfect_week_bonus", + "streak_milestones", + "quick_point_amounts", + "streak_requires_all_chores", + "perfect_week_requires_all_chores", + "difficulty_multiplier_easy", + "difficulty_multiplier_medium", + "difficulty_multiplier_hard", + "unlock_allowlist", + "parent_routing", + "read_aloud_media_player", + "read_aloud_tts_entity", + "read_aloud_template", + "read_aloud_one_template", + "read_aloud_done_template", + "read_aloud_joiner", + "roulette_enabled", + "roulette_multiplier", + "roulette_daily_spins", + "surprise_bonus_enabled", + "surprise_bonus_chance", + "surprise_bonus_min", + "surprise_bonus_max", + "points_decay_enabled", + "points_decay_period", + "points_decay_percent", "level_xp_step", - "spend_cap_enabled", "spend_cap_period", "spend_cap_amount", - "interest_enabled", "interest_period", "interest_percent", - "celebration_notify", "celebration_notify_min_tier", + "spend_cap_enabled", + "spend_cap_period", + "spend_cap_amount", + "interest_enabled", + "interest_period", + "interest_percent", + "celebration_notify", + "celebration_notify_min_tier", "allow_negative_balance", - "allowance_enabled", "allowance_rate", "allowance_currency", - "family_goal_enabled", "family_goal_name", "family_goal_target", "family_goal_reward", - "notify_service", "calendar_projection_days", "skip_confirmation_enabled", + "allowance_enabled", + "allowance_rate", + "allowance_currency", + "family_goal_enabled", + "family_goal_name", + "family_goal_target", + "family_goal_reward", + "notify_service", + "calendar_projection_days", + "skip_confirmation_enabled", "vacation_calendar", - "time_morning_start", "time_morning_end", - "time_afternoon_start", "time_afternoon_end", - "time_evening_start", "time_evening_end", - "time_night_start", "time_night_end", + "time_morning_start", + "time_morning_end", + "time_afternoon_start", + "time_afternoon_end", + "time_evening_start", + "time_evening_end", + "time_night_start", + "time_night_end", } @@ -1430,30 +1630,28 @@ def _validate_time_periods(raw: list, coordinator) -> tuple[list[dict] | None, s if start >= end: return None, f"period '{label or pid}' must start before it ends" ids.add(pid) - periods.append({ - "id": pid, - "label": label, - "start": start, - "end": end, - "icon": str(entry.get("icon") or "").strip()[:120] - or TIME_CATEGORY_ICONS.get(pid, "mdi:clock-outline"), - }) + periods.append( + { + "id": pid, + "label": label, + "start": start, + "end": end, + "icon": str(entry.get("icon") or "").strip()[:120] or TIME_CATEGORY_ICONS.get(pid, "mdi:clock-outline"), + } + ) periods.sort(key=lambda p: p["start"]) for prev, cur in zip(periods, periods[1:], strict=False): if cur["start"] < prev["end"]: return None, ( - f"'{cur['label'] or cur['id']}' overlaps " - f"'{prev['label'] or prev['id']}' — periods cannot overlap" + f"'{cur['label'] or cur['id']}' overlaps '{prev['label'] or prev['id']}' — periods cannot overlap" ) removed = {p["id"] for p in coordinator.get_time_periods()} - {p["id"] for p in periods} if removed: - in_use = sorted({ - chore.name or chore.id - for chore in coordinator.storage.get_chores() - if chore.time_category in removed - }) + in_use = sorted( + {chore.name or chore.id for chore in coordinator.storage.get_chores() if chore.time_category in removed} + ) if in_use: return None, ( "cannot delete a period still used by chores: " @@ -1495,14 +1693,17 @@ def _validate_vacation_periods(raw: list) -> tuple[list[dict] | None, str | None if not pid or pid in taken: pid = f"{start.isoformat()}_{len(periods)}" taken.add(pid) - periods.append({ - "id": pid, - "name": str(entry.get("name") or "").strip(), - "start": start.isoformat(), - "end": end.isoformat(), - }) + periods.append( + { + "id": pid, + "name": str(entry.get("name") or "").strip(), + "start": start.isoformat(), + "end": end.isoformat(), + } + ) return sorted(periods, key=lambda p: p["start"]), None + # Extracted to a module constant so the accepted settings keys can be unit-tested # (the websocket_command decorator does not expose the compiled schema). Every key # accepted here must also be routed in _ws_update_settings below. @@ -1635,12 +1836,15 @@ async def _ws_update_settings(hass, connection, msg, coordinator): # Operational — approval / rejection / reorder / bulk add # --------------------------------------------------------------------------- -@websocket_api.websocket_command({ - vol.Required("type"): WS_COMPLETE_BONUS_SUBTASK, - vol.Required("chore_id"): str, - vol.Required("bonus_subtask_id"): str, - vol.Required("child_id"): str, -}) + +@websocket_api.websocket_command( + { + vol.Required("type"): WS_COMPLETE_BONUS_SUBTASK, + vol.Required("chore_id"): str, + vol.Required("bonus_subtask_id"): str, + vol.Required("child_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_complete_bonus_subtask(hass, connection, msg, coordinator): @@ -1650,10 +1854,12 @@ async def _ws_complete_bonus_subtask(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": completion.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_APPROVE_CHORE, - vol.Required("completion_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_APPROVE_CHORE, + vol.Required("completion_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_approve_chore(hass, connection, msg, coordinator): @@ -1661,10 +1867,12 @@ async def _ws_approve_chore(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"completion_id": msg["completion_id"]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_APPROVE_ALL_CHORES, - vol.Optional("completion_ids"): [str], -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_APPROVE_ALL_CHORES, + vol.Optional("completion_ids"): [str], + } +) @websocket_api.async_response @_admin_only async def _ws_approve_all_chores(hass, connection, msg, coordinator): @@ -1672,10 +1880,12 @@ async def _ws_approve_all_chores(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"count": count}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_REJECT_CHORE, - vol.Required("completion_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REJECT_CHORE, + vol.Required("completion_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_reject_chore(hass, connection, msg, coordinator): @@ -1683,10 +1893,12 @@ async def _ws_reject_chore(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"completion_id": msg["completion_id"]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_APPROVE_REWARD, - vol.Required("claim_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_APPROVE_REWARD, + vol.Required("claim_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_approve_reward(hass, connection, msg, coordinator): @@ -1694,10 +1906,12 @@ async def _ws_approve_reward(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"claim_id": msg["claim_id"]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_REJECT_REWARD, - vol.Required("claim_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REJECT_REWARD, + vol.Required("claim_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_reject_reward(hass, connection, msg, coordinator): @@ -1705,10 +1919,12 @@ async def _ws_reject_reward(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"claim_id": msg["claim_id"]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_PARENT_COMPLETE_CHORE, - vol.Required("chore_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_PARENT_COMPLETE_CHORE, + vol.Required("chore_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_parent_complete_chore(hass, connection, msg, coordinator): @@ -1716,11 +1932,13 @@ async def _ws_parent_complete_chore(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"ok": True}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_SET_CHORE_ORDER, - vol.Required("child_id"): str, - vol.Required("chore_order"): [str], -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_SET_CHORE_ORDER, + vol.Required("child_id"): str, + vol.Required("chore_order"): [str], + } +) @websocket_api.async_response @_admin_only async def _ws_set_chore_order(hass, connection, msg, coordinator): @@ -1728,10 +1946,12 @@ async def _ws_set_chore_order(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"child_id": msg["child_id"]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_SET_GLOBAL_CHORE_ORDER, - vol.Required("chore_order"): [str], -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_SET_GLOBAL_CHORE_ORDER, + vol.Required("chore_order"): [str], + } +) @websocket_api.async_response @_admin_only async def _ws_set_global_chore_order(hass, connection, msg, coordinator): @@ -1739,18 +1959,20 @@ async def _ws_set_global_chore_order(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"ok": True}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_ADD_CHORES_BULK, - vol.Required("chore_names"): [str], - vol.Optional("points"): vol.All(int, vol.Range(min=0)), - vol.Optional("assigned_to"): [str], - vol.Optional("requires_approval"): bool, - vol.Optional("time_category"): str, - vol.Optional("schedule_mode"): vol.In(["specific_days", "recurring", "one_shot"]), - vol.Optional("due_days"): [str], - vol.Optional("daily_limit"): vol.All(int, vol.Range(min=1)), - vol.Optional("completion_sound"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_ADD_CHORES_BULK, + vol.Required("chore_names"): [str], + vol.Optional("points"): vol.All(int, vol.Range(min=0)), + vol.Optional("assigned_to"): [str], + vol.Optional("requires_approval"): bool, + vol.Optional("time_category"): str, + vol.Optional("schedule_mode"): vol.In(["specific_days", "recurring", "one_shot"]), + vol.Optional("due_days"): [str], + vol.Optional("daily_limit"): vol.All(int, vol.Range(min=1)), + vol.Optional("completion_sound"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_add_chores_bulk(hass, connection, msg, coordinator): @@ -1776,6 +1998,7 @@ async def _ws_add_chores_bulk(hass, connection, msg, coordinator): # Templates # --------------------------------------------------------------------------- + @websocket_api.websocket_command({vol.Required("type"): WS_TEMPLATES_LIST}) @websocket_api.async_response @_admin_only @@ -1783,10 +2006,12 @@ async def _ws_templates_list(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"templates": coordinator.get_all_templates()}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_TEMPLATES_GET, - vol.Required("template_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_TEMPLATES_GET, + vol.Required("template_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_templates_get(hass, connection, msg, coordinator): @@ -1797,34 +2022,40 @@ async def _ws_templates_get(hass, connection, msg, coordinator): connection.send_result(msg["id"], tpl) -@websocket_api.websocket_command({ - vol.Required("type"): WS_TEMPLATES_APPLY, - vol.Required("chores"): [{ - vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Optional("points"): vol.All(int, vol.Range(min=0)), - vol.Optional("description"): str, - vol.Optional("assigned_to"): [str], - vol.Optional("requires_approval"): bool, - vol.Optional("time_category"): str, - vol.Optional("daily_limit"): vol.All(int, vol.Range(min=1)), - vol.Optional("completion_sound"): str, - vol.Optional("schedule_mode"): vol.In(["specific_days", "recurring", "one_shot"]), - vol.Optional("due_days"): [str], - vol.Optional("recurrence"): str, - vol.Optional("recurrence_day"): str, - vol.Optional("recurrence_start"): str, - vol.Optional("first_occurrence_mode"): str, - vol.Optional("assignment_mode"): vol.In(["everyone", "alternating", "random", "balanced", "first_come", "unassigned"]), - vol.Optional("require_availability"): bool, - vol.Optional("visibility_entity"): str, - vol.Optional("visibility_state"): str, - vol.Optional("visibility_operator"): str, - vol.Optional("task_type"): vol.In(["standard", "timed"]), - vol.Optional("timed_rate_points"): vol.All(int, vol.Range(min=1)), - vol.Optional("timed_rate_minutes"): vol.All(int, vol.Range(min=1)), - vol.Optional("timed_max_daily_minutes"): vol.All(int, vol.Range(min=0)), - }], -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_TEMPLATES_APPLY, + vol.Required("chores"): [ + { + vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("points"): vol.All(int, vol.Range(min=0)), + vol.Optional("description"): str, + vol.Optional("assigned_to"): [str], + vol.Optional("requires_approval"): bool, + vol.Optional("time_category"): str, + vol.Optional("daily_limit"): vol.All(int, vol.Range(min=1)), + vol.Optional("completion_sound"): str, + vol.Optional("schedule_mode"): vol.In(["specific_days", "recurring", "one_shot"]), + vol.Optional("due_days"): [str], + vol.Optional("recurrence"): str, + vol.Optional("recurrence_day"): str, + vol.Optional("recurrence_start"): str, + vol.Optional("first_occurrence_mode"): str, + vol.Optional("assignment_mode"): vol.In( + ["everyone", "alternating", "random", "balanced", "first_come", "unassigned"] + ), + vol.Optional("require_availability"): bool, + vol.Optional("visibility_entity"): str, + vol.Optional("visibility_state"): str, + vol.Optional("visibility_operator"): str, + vol.Optional("task_type"): vol.In(["standard", "timed"]), + vol.Optional("timed_rate_points"): vol.All(int, vol.Range(min=1)), + vol.Optional("timed_rate_minutes"): vol.All(int, vol.Range(min=1)), + vol.Optional("timed_max_daily_minutes"): vol.All(int, vol.Range(min=0)), + } + ], + } +) @websocket_api.async_response @_admin_only async def _ws_templates_apply(hass, connection, msg, coordinator): @@ -1832,12 +2063,14 @@ async def _ws_templates_apply(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"created_ids": created_ids}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_TEMPLATES_SAVE_FROM, - vol.Required("chore_ids"): vol.All([str], vol.Length(min=1)), - vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Optional("icon", default="mdi:clipboard-list"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_TEMPLATES_SAVE_FROM, + vol.Required("chore_ids"): vol.All([str], vol.Length(min=1)), + vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("icon", default="mdi:clipboard-list"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_templates_save_from(hass, connection, msg, coordinator): @@ -1849,12 +2082,14 @@ async def _ws_templates_save_from(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"template_id": tpl_id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_TEMPLATES_CREATE, - vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Optional("icon", default="mdi:clipboard-list"): str, - vol.Required("chores"): vol.All(list, vol.Length(min=1)), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_TEMPLATES_CREATE, + vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("icon", default="mdi:clipboard-list"): str, + vol.Required("chores"): vol.All(list, vol.Length(min=1)), + } +) @websocket_api.async_response @_admin_only async def _ws_templates_create(hass, connection, msg, coordinator): @@ -1866,13 +2101,15 @@ async def _ws_templates_create(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"template_id": tpl_id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_TEMPLATES_UPDATE, - vol.Required("template_id"): str, - vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Optional("icon"): str, - vol.Optional("chores"): vol.All(list, vol.Length(min=1)), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_TEMPLATES_UPDATE, + vol.Required("template_id"): str, + vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("icon"): str, + vol.Optional("chores"): vol.All(list, vol.Length(min=1)), + } +) @websocket_api.async_response @_admin_only async def _ws_templates_update(hass, connection, msg, coordinator): @@ -1885,10 +2122,12 @@ async def _ws_templates_update(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"success": True}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_TEMPLATES_DELETE, - vol.Required("template_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_TEMPLATES_DELETE, + vol.Required("template_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_templates_delete(hass, connection, msg, coordinator): @@ -1900,11 +2139,13 @@ async def _ws_templates_delete(hass, connection, msg, coordinator): # Notifications # --------------------------------------------------------------------------- + @websocket_api.websocket_command({vol.Required("type"): WS_NOTIF_GET_STATE}) @websocket_api.async_response @_admin_only async def ws_notif_get_state(hass, connection, msg, coordinator): from .coord_notifications import NOTIFICATION_TYPES + c = coordinator state = { "recipients": { @@ -1931,28 +2172,25 @@ async def ws_notif_get_state(hass, connection, msg, coordinator): } for t in NOTIFICATION_TYPES ], - "config": { - tid: cfg.to_dict() - for tid, cfg in c.storage.get_all_notification_configs().items() - }, + "config": {tid: cfg.to_dict() for tid, cfg in c.storage.get_all_notification_configs().items()}, "custom": [n.to_dict() for n in c.storage.get_custom_notifications()], "settings": { "streak_at_risk_cutoff_time": c.storage.get_streak_at_risk_cutoff(), "mandatory_escalation_reminder_minutes": c.storage.get_escalation_reminder_minutes(), "mandatory_escalation_parent_minutes": c.storage.get_escalation_parent_minutes(), - "notification_nav_url": c.storage.get_setting( - "notification_nav_url", DEFAULT_NOTIFICATION_NAV_URL - ), + "notification_nav_url": c.storage.get_setting("notification_nav_url", DEFAULT_NOTIFICATION_NAV_URL), }, } connection.send_result(msg["id"], state) -@websocket_api.websocket_command({ - vol.Required("type"): WS_NOTIF_SET_MASTER, - vol.Required("type_id"): str, - vol.Required("enabled"): bool, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_NOTIF_SET_MASTER, + vol.Required("type_id"): str, + vol.Required("enabled"): bool, + } +) @websocket_api.async_response @_admin_only async def ws_notif_set_master(hass, connection, msg, coordinator): @@ -1960,27 +2198,32 @@ async def ws_notif_set_master(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"ok": True}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_NOTIF_SET_ROUTE, - vol.Required("type_id"): str, - vol.Required("recipient_id"): str, - vol.Required("enabled"): bool, - vol.Optional("time"): vol.Any(str, None), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_NOTIF_SET_ROUTE, + vol.Required("type_id"): str, + vol.Required("recipient_id"): str, + vol.Required("enabled"): bool, + vol.Optional("time"): vol.Any(str, None), + } +) @websocket_api.async_response @_admin_only async def ws_notif_set_route(hass, connection, msg, coordinator): from .models import NotificationRoute + route = NotificationRoute(enabled=msg["enabled"], time=msg.get("time")) await coordinator.notifications.set_route(msg["type_id"], msg["recipient_id"], route) connection.send_result(msg["id"], {"ok": True}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_NOTIF_SET_CHILD_NOTIFY, - vol.Required("child_id"): str, - vol.Required("notify_service"): vol.Any(str, None), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_NOTIF_SET_CHILD_NOTIFY, + vol.Required("child_id"): str, + vol.Required("notify_service"): vol.Any(str, None), + } +) @websocket_api.async_response @_admin_only async def ws_notif_set_child_notify(hass, connection, msg, coordinator): @@ -2011,12 +2254,14 @@ def _validate_hhmm_or_empty(value): return f"{hour:02d}:{minute:02d}" -@websocket_api.websocket_command({ - vol.Required("type"): WS_NOTIF_SET_CHILD_QUIET, - vol.Required("child_id"): str, - vol.Required("quiet_hours_start"): _validate_hhmm_or_empty, - vol.Required("quiet_hours_end"): _validate_hhmm_or_empty, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_NOTIF_SET_CHILD_QUIET, + vol.Required("child_id"): str, + vol.Required("quiet_hours_start"): _validate_hhmm_or_empty, + vol.Required("quiet_hours_end"): _validate_hhmm_or_empty, + } +) @websocket_api.async_response @_admin_only async def ws_notif_set_child_quiet(hass, connection, msg, coordinator): @@ -2032,24 +2277,25 @@ async def ws_notif_set_child_quiet(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"ok": True}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_NOTIF_UPSERT_PARENT, - vol.Optional("parent_id"): str, - vol.Required("name"): str, - vol.Required("notify_service"): str, - vol.Optional("enabled", default=True): bool, - vol.Optional("presence_entity", default=""): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_NOTIF_UPSERT_PARENT, + vol.Optional("parent_id"): str, + vol.Required("name"): str, + vol.Required("notify_service"): str, + vol.Optional("enabled", default=True): bool, + vol.Optional("presence_entity", default=""): str, + } +) @websocket_api.async_response @_admin_only async def ws_notif_upsert_parent(hass, connection, msg, coordinator): from .models import ParentRecipient + c = coordinator p_id = msg.get("parent_id") if p_id: - existing = next( - (p for p in c.storage.get_parent_recipients() if p.id == p_id), None - ) + existing = next((p for p in c.storage.get_parent_recipients() if p.id == p_id), None) if existing is None: connection.send_error(msg["id"], "not_found", "Parent not found") return @@ -2070,10 +2316,12 @@ async def ws_notif_upsert_parent(hass, connection, msg, coordinator): connection.send_result(msg["id"], p.to_dict()) -@websocket_api.websocket_command({ - vol.Required("type"): WS_NOTIF_DELETE_PARENT, - vol.Required("parent_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_NOTIF_DELETE_PARENT, + vol.Required("parent_id"): str, + } +) @websocket_api.async_response @_admin_only async def ws_notif_delete_parent(hass, connection, msg, coordinator): @@ -2081,38 +2329,45 @@ async def ws_notif_delete_parent(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"ok": True}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_NOTIF_UPSERT_CUSTOM, - vol.Optional("custom_id"): str, - vol.Required("name"): str, - vol.Required("message_template"): str, - vol.Required("time"): str, - vol.Optional("day_mask", default=0b1111111): int, - vol.Optional("recipient_ids", default=list): list, - vol.Optional("enabled", default=True): bool, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_NOTIF_UPSERT_CUSTOM, + vol.Optional("custom_id"): str, + vol.Required("name"): str, + vol.Required("message_template"): str, + vol.Required("time"): str, + vol.Optional("day_mask", default=0b1111111): int, + vol.Optional("recipient_ids", default=list): list, + vol.Optional("enabled", default=True): bool, + } +) @websocket_api.async_response @_admin_only async def ws_notif_upsert_custom(hass, connection, msg, coordinator): from .models import CustomNotification + c = coordinator - n = CustomNotification.from_dict({ - "id": msg.get("custom_id"), - "name": msg["name"], - "message_template": msg["message_template"], - "time": msg["time"], - "day_mask": int(msg["day_mask"]), - "recipient_ids": list(msg["recipient_ids"]), - "enabled": bool(msg["enabled"]), - }) + n = CustomNotification.from_dict( + { + "id": msg.get("custom_id"), + "name": msg["name"], + "message_template": msg["message_template"], + "time": msg["time"], + "day_mask": int(msg["day_mask"]), + "recipient_ids": list(msg["recipient_ids"]), + "enabled": bool(msg["enabled"]), + } + ) await c.notifications.upsert_custom(n) connection.send_result(msg["id"], n.to_dict()) -@websocket_api.websocket_command({ - vol.Required("type"): WS_NOTIF_DELETE_CUSTOM, - vol.Required("custom_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_NOTIF_DELETE_CUSTOM, + vol.Required("custom_id"): str, + } +) @websocket_api.async_response @_admin_only async def ws_notif_delete_custom(hass, connection, msg, coordinator): @@ -2124,18 +2379,17 @@ async def ws_notif_delete_custom(hass, connection, msg, coordinator): @websocket_api.async_response @_admin_only async def ws_notif_list_notify(hass, connection, msg, coordinator): - services = [ - f"notify.{name}" - for name in hass.services.async_services().get("notify", {}) - ] + services = [f"notify.{name}" for name in hass.services.async_services().get("notify", {})] services.sort() connection.send_result(msg["id"], services) -@websocket_api.websocket_command({ - vol.Required("type"): WS_NOTIF_SET_STREAK_CUTOFF, - vol.Required("time"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_NOTIF_SET_STREAK_CUTOFF, + vol.Required("time"): str, + } +) @websocket_api.async_response @_admin_only async def ws_notif_set_streak_cutoff(hass, connection, msg, coordinator): @@ -2143,25 +2397,27 @@ async def ws_notif_set_streak_cutoff(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"ok": True}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_NOTIF_SET_ESCALATION, - vol.Required("reminder_minutes"): vol.All(vol.Coerce(int), vol.Range(min=1, max=1440)), - vol.Required("parent_minutes"): vol.All(vol.Coerce(int), vol.Range(min=1, max=1440)), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_NOTIF_SET_ESCALATION, + vol.Required("reminder_minutes"): vol.All(vol.Coerce(int), vol.Range(min=1, max=1440)), + vol.Required("parent_minutes"): vol.All(vol.Coerce(int), vol.Range(min=1, max=1440)), + } +) @websocket_api.async_response @_admin_only async def ws_notif_set_escalation(hass, connection, msg, coordinator): - coordinator.storage.set_escalation_minutes( - msg["reminder_minutes"], msg["parent_minutes"] - ) + coordinator.storage.set_escalation_minutes(msg["reminder_minutes"], msg["parent_minutes"]) await coordinator.storage.async_save() connection.send_result(msg["id"], {"ok": True}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_NOTIF_SEND_TEST, - vol.Required("type_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_NOTIF_SEND_TEST, + vol.Required("type_id"): str, + } +) @websocket_api.async_response @_admin_only async def ws_notif_send_test(hass, connection, msg, coordinator): @@ -2169,11 +2425,13 @@ async def ws_notif_send_test(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"sent": sent}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_NOTIF_SET_NAV_URL, - vol.Optional("type_id"): vol.Any(str, None), - vol.Required("nav_url"): vol.All(str, vol.Length(max=200)), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_NOTIF_SET_NAV_URL, + vol.Optional("type_id"): vol.Any(str, None), + vol.Required("nav_url"): vol.All(str, vol.Length(max=200)), + } +) @websocket_api.async_response @_admin_only async def ws_notif_set_nav_url(hass, connection, msg, coordinator): @@ -2185,11 +2443,14 @@ async def ws_notif_set_nav_url(hass, connection, msg, coordinator): # Calendar ICS feed (FEAT-10) # --------------------------------------------------------------------------- + def _build_ics_url(hass, token: str) -> str: from .http_calendar import CALENDAR_URL + base = "" try: from homeassistant.helpers.network import get_url + base = get_url(hass, prefer_external=True) except Exception: # noqa: BLE001 - no configured URL yet base = "" @@ -2216,6 +2477,7 @@ async def ws_cal_regen_token(hass, connection, msg, coordinator): # Admin audit log # --------------------------------------------------------------------------- + @websocket_api.websocket_command({vol.Required("type"): WS_AUDIT_LIST}) @websocket_api.async_response @_admin_only @@ -2232,10 +2494,12 @@ async def _ws_audit_clear(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"cleared": True}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_UNDO_TRANSACTION, - vol.Required("transaction_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_UNDO_TRANSACTION, + vol.Required("transaction_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_undo_transaction(hass, connection, msg, coordinator): @@ -2243,10 +2507,12 @@ async def _ws_undo_transaction(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"undone": msg["transaction_id"]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_CLONE_CHORE, - vol.Required("chore_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_CLONE_CHORE, + vol.Required("chore_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_clone_chore(hass, connection, msg, coordinator): @@ -2254,27 +2520,33 @@ async def _ws_clone_chore(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": clone.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_BULK_CHORE_ACTION, - vol.Required("action"): vol.In(["delete", "enable", "disable", "reassign"]), - vol.Required("chore_ids"): [str], - vol.Optional("assigned_to"): [str], -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_BULK_CHORE_ACTION, + vol.Required("action"): vol.In(["delete", "enable", "disable", "reassign"]), + vol.Required("chore_ids"): [str], + vol.Optional("assigned_to"): [str], + } +) @websocket_api.async_response @_admin_only async def _ws_bulk_chore_action(hass, connection, msg, coordinator): count = await coordinator.async_bulk_chore_action( - msg["action"], msg["chore_ids"], msg.get("assigned_to"), + msg["action"], + msg["chore_ids"], + msg.get("assigned_to"), ) connection.send_result(msg["id"], {"count": count}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_GIFT_POINTS, - vol.Required("from_child_id"): str, - vol.Required("to_child_id"): str, - vol.Required("points"): vol.All(int, vol.Range(min=1)), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_GIFT_POINTS, + vol.Required("from_child_id"): str, + vol.Required("to_child_id"): str, + vol.Required("points"): vol.All(int, vol.Range(min=1)), + } +) @websocket_api.async_response @_admin_only async def _ws_gift_points(hass, connection, msg, coordinator): @@ -2282,11 +2554,13 @@ async def _ws_gift_points(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"ok": True}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_REQUEST_SWAP, - vol.Required("chore_id"): str, - vol.Required("requester_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REQUEST_SWAP, + vol.Required("chore_id"): str, + vol.Required("requester_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_request_swap(hass, connection, msg, coordinator): @@ -2294,10 +2568,12 @@ async def _ws_request_swap(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": rid}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_APPROVE_SWAP, - vol.Required("request_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_APPROVE_SWAP, + vol.Required("request_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_approve_swap(hass, connection, msg, coordinator): @@ -2305,10 +2581,12 @@ async def _ws_approve_swap(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"ok": True}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_REJECT_SWAP, - vol.Required("request_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REJECT_SWAP, + vol.Required("request_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_reject_swap(hass, connection, msg, coordinator): @@ -2323,10 +2601,12 @@ async def _ws_config_export(hass, connection, msg, coordinator): connection.send_result(msg["id"], coordinator.export_config()) -@websocket_api.websocket_command({ - vol.Required("type"): WS_CONFIG_IMPORT, - vol.Required("payload"): dict, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_CONFIG_IMPORT, + vol.Required("payload"): dict, + } +) @websocket_api.async_response @_admin_only async def _ws_config_import(hass, connection, msg, coordinator): @@ -2340,37 +2620,90 @@ async def _ws_config_import(hass, connection, msg, coordinator): _COMMANDS = ( _ws_get_state, - _ws_audit_list, _ws_audit_clear, _ws_undo_transaction, - _ws_add_child, _ws_update_child, _ws_remove_child, _ws_list_ha_users, - _ws_add_chore, _ws_update_chore, _ws_remove_chore, _ws_clone_chore, - _ws_scheduled_list, _ws_scheduled_add, _ws_scheduled_remove, - _ws_report_fairness, _ws_report_friction, _ws_report_projection, _ws_report_health, - _ws_templates_export, _ws_templates_import, _ws_print_chart, - _ws_bulk_chore_action, _ws_gift_points, - _ws_request_swap, _ws_approve_swap, _ws_reject_swap, - _ws_config_export, _ws_config_import, - _ws_add_reward, _ws_update_reward, _ws_remove_reward, - _ws_create_quest, _ws_update_quest, _ws_delete_quest, - _ws_update_avatar_catalog, _ws_set_child_avatar, - _ws_create_challenge, _ws_update_challenge, _ws_delete_challenge, - _ws_add_penalty, _ws_update_penalty, _ws_remove_penalty, _ws_apply_penalty, - _ws_add_bonus, _ws_update_bonus, _ws_remove_bonus, _ws_apply_bonus, - _ws_add_task_group, _ws_update_task_group, _ws_remove_task_group, + _ws_audit_list, + _ws_audit_clear, + _ws_undo_transaction, + _ws_add_child, + _ws_update_child, + _ws_remove_child, + _ws_list_ha_users, + _ws_add_chore, + _ws_update_chore, + _ws_remove_chore, + _ws_clone_chore, + _ws_scheduled_list, + _ws_scheduled_add, + _ws_scheduled_remove, + _ws_report_fairness, + _ws_report_friction, + _ws_report_projection, + _ws_report_health, + _ws_templates_export, + _ws_templates_import, + _ws_print_chart, + _ws_bulk_chore_action, + _ws_gift_points, + _ws_request_swap, + _ws_approve_swap, + _ws_reject_swap, + _ws_config_export, + _ws_config_import, + _ws_add_reward, + _ws_update_reward, + _ws_remove_reward, + _ws_create_quest, + _ws_update_quest, + _ws_delete_quest, + _ws_update_avatar_catalog, + _ws_set_child_avatar, + _ws_create_challenge, + _ws_update_challenge, + _ws_delete_challenge, + _ws_add_penalty, + _ws_update_penalty, + _ws_remove_penalty, + _ws_apply_penalty, + _ws_add_bonus, + _ws_update_bonus, + _ws_remove_bonus, + _ws_apply_bonus, + _ws_add_task_group, + _ws_update_task_group, + _ws_remove_task_group, _ws_update_settings, _ws_complete_bonus_subtask, - _ws_approve_chore, _ws_approve_all_chores, _ws_reject_chore, _ws_approve_reward, _ws_reject_reward, + _ws_approve_chore, + _ws_approve_all_chores, + _ws_reject_chore, + _ws_approve_reward, + _ws_reject_reward, _ws_parent_complete_chore, - _ws_set_chore_order, _ws_set_global_chore_order, _ws_add_chores_bulk, - _ws_templates_list, _ws_templates_get, _ws_templates_apply, - _ws_templates_save_from, _ws_templates_create, _ws_templates_update, + _ws_set_chore_order, + _ws_set_global_chore_order, + _ws_add_chores_bulk, + _ws_templates_list, + _ws_templates_get, + _ws_templates_apply, + _ws_templates_save_from, + _ws_templates_create, + _ws_templates_update, _ws_templates_delete, - ws_notif_get_state, ws_notif_set_master, ws_notif_set_route, - ws_notif_set_child_notify, ws_notif_set_child_quiet, - ws_notif_upsert_parent, ws_notif_delete_parent, - ws_notif_upsert_custom, ws_notif_delete_custom, - ws_notif_list_notify, ws_notif_set_streak_cutoff, ws_notif_send_test, - ws_notif_set_escalation, ws_notif_set_nav_url, - ws_cal_get_url, ws_cal_regen_token, + ws_notif_get_state, + ws_notif_set_master, + ws_notif_set_route, + ws_notif_set_child_notify, + ws_notif_set_child_quiet, + ws_notif_upsert_parent, + ws_notif_delete_parent, + ws_notif_upsert_custom, + ws_notif_delete_custom, + ws_notif_list_notify, + ws_notif_set_streak_cutoff, + ws_notif_send_test, + ws_notif_set_escalation, + ws_notif_set_nav_url, + ws_cal_get_url, + ws_cal_regen_token, ) diff --git a/custom_components/taskmate/www/locales/de.json b/custom_components/taskmate/www/locales/de.json index 7436bdee..726cf2cb 100644 --- a/custom_components/taskmate/www/locales/de.json +++ b/custom_components/taskmate/www/locales/de.json @@ -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}", diff --git a/custom_components/taskmate/www/locales/en-GB.json b/custom_components/taskmate/www/locales/en-GB.json index 7d2c4c04..3ff6d357 100644 --- a/custom_components/taskmate/www/locales/en-GB.json +++ b/custom_components/taskmate/www/locales/en-GB.json @@ -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}", diff --git a/custom_components/taskmate/www/locales/en.json b/custom_components/taskmate/www/locales/en.json index 7d2c4c04..3ff6d357 100644 --- a/custom_components/taskmate/www/locales/en.json +++ b/custom_components/taskmate/www/locales/en.json @@ -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}", diff --git a/custom_components/taskmate/www/locales/fr.json b/custom_components/taskmate/www/locales/fr.json index f128e4c4..079ad28f 100644 --- a/custom_components/taskmate/www/locales/fr.json +++ b/custom_components/taskmate/www/locales/fr.json @@ -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}", diff --git a/custom_components/taskmate/www/locales/nb.json b/custom_components/taskmate/www/locales/nb.json index 29ca809f..11581e50 100644 --- a/custom_components/taskmate/www/locales/nb.json +++ b/custom_components/taskmate/www/locales/nb.json @@ -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}", diff --git a/custom_components/taskmate/www/locales/nn.json b/custom_components/taskmate/www/locales/nn.json index aeff3da4..a8382d99 100644 --- a/custom_components/taskmate/www/locales/nn.json +++ b/custom_components/taskmate/www/locales/nn.json @@ -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}", diff --git a/custom_components/taskmate/www/locales/pt-BR.json b/custom_components/taskmate/www/locales/pt-BR.json index b0429502..34dde7ff 100644 --- a/custom_components/taskmate/www/locales/pt-BR.json +++ b/custom_components/taskmate/www/locales/pt-BR.json @@ -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}", diff --git a/custom_components/taskmate/www/locales/pt.json b/custom_components/taskmate/www/locales/pt.json index 0d8095f6..2b7eeebb 100644 --- a/custom_components/taskmate/www/locales/pt.json +++ b/custom_components/taskmate/www/locales/pt.json @@ -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}", diff --git a/custom_components/taskmate/www/taskmate-child-card.js b/custom_components/taskmate/www/taskmate-child-card.js index d3bf56de..755164d3 100644 --- a/custom_components/taskmate/www/taskmate-child-card.js +++ b/custom_components/taskmate/www/taskmate-child-card.js @@ -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 { ` : ''} + ${nextBadge ? html` +
this._openBadgesView()} + @keydown=${(e) => this._badgeKeydown(e)}> +
+ +
+
+
+ ${this._t('badges.next_up')} + ${nextBadge.name} + ${nextBadge.label} +
+
+ +
+
+
+ ` : ''} +
${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_ (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 {
`)} ${earnedBadges.length > 5 ? html`+${earnedBadges.length - 5} →` : ""} ` : ""} + ${nextBadge ? html` +
this._openBadgesView()} + @keydown=${(e) => this._badgeKeydown(e)}> +
+ +
+
+
+ ${this._t("badges.next_up")} + ${nextBadge.name} + ${nextBadge.label} +
+
+ +
+
+
` : ""} ${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;" -); \ No newline at end of file +); diff --git a/zigbee2mqtt/state.json b/zigbee2mqtt/state.json index b7afb35b..39c643c4 100644 --- a/zigbee2mqtt/state.json +++ b/zigbee2mqtt/state.json @@ -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,