278 files

This commit is contained in:
Home Assistant Version Control
2026-09-13 20:38:58 +00:00
parent d7a0a08372
commit 1b4f5f68c6
278 changed files with 36892 additions and 7150 deletions
+63 -16
View File
@@ -18,6 +18,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.service import async_set_service_schema
from homeassistant.util import dt as dt_util
from . import authz
from .const import (
ATTR_AS_PARENT,
ATTR_AWARDED_BADGE_ID,
@@ -62,7 +63,6 @@ from .const import (
ATTR_REWARD_ID,
ATTR_SOUND,
BADGE_TIERS,
COMPLETION_SOUND_OPTIONS,
CONF_TASK_GROUP_CHORE_IDS,
CONF_TASK_GROUP_ID,
CONF_TASK_GROUP_NAME,
@@ -384,6 +384,19 @@ async def _async_record_service_audit(hass: HomeAssistant, call: ServiceCall) ->
_LOGGER.debug("Failed to record service audit for %s", call.service, exc_info=True)
def _valid_sound(value: str) -> str:
"""Validate a preview_sound name: a built-in, or an uploaded custom sound (#856).
Replaces a plain ``vol.In(COMPLETION_SOUND_OPTIONS)``, which could not see
custom sounds because they are created at runtime.
"""
from .const import is_valid_completion_sound
if is_valid_completion_sound(value):
return value
raise vol.Invalid(f"Unknown completion sound: {value}")
def _safe(handler):
"""Surface coordinator validation errors as clean service errors.
@@ -433,6 +446,13 @@ async def _async_require_linked_child(hass: HomeAssistant, call: ServiceCall, co
others = coordinator.storage.get_children() or []
if any(getattr(c, "linked_user_id", "") == user_id for c in others):
raise Unauthorized(context=call.context)
# Strict mode: households where every child has their own HA login can
# require a link, so an unlinked child profile is no longer an open door
# for any authenticated user. Parents keep acting on any child's behalf.
if coordinator.storage.get_require_linked_child():
if user_id in (coordinator.storage.get_parent_user_ids() or []):
return
raise Unauthorized(context=call.context)
async def _async_register_services(hass: HomeAssistant) -> None:
@@ -452,6 +472,23 @@ async def _async_register_services(hass: HomeAssistant) -> None:
# Unauthorized (not ValueError), so it is unaffected and still 401s.
return _safe(wrapped)
def _audited(handler):
"""Wrap a child-facing handler so its mutation is recorded too.
Only the admin and parent wrappers used to audit. Everything a child
can drive — completing a chore, claiming a reward, allocating points —
therefore left no trail at all, which is exactly the set of actions
worth being able to review. The guards stay inside each handler; this
only adds the record.
"""
@wraps(handler)
async def wrapped(call: ServiceCall) -> None:
await handler(call)
await _async_record_service_audit(hass, call)
return _safe(wrapped)
def _parent(handler):
"""Like _admin, but also allows non-admin users in parent_user_ids (#661).
@@ -651,12 +688,20 @@ 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])
# A child may have their own chore list read out; choosing the words is
# a parent action. Otherwise any account that can act as a child could
# make a speaker in the house say anything, at any hour.
message = call.data.get("message", "")
if message and not await authz.async_context_is_parent(hass, coordinator, call.context):
_LOGGER.warning("Ignoring caller-supplied read_aloud message from a non-parent user")
message = ""
try:
await coordinator.async_read_aloud(
child_id=call.data[ATTR_CHILD_ID],
media_player=call.data.get("media_player", ""),
tts_entity=call.data.get("tts_entity", ""),
message=call.data.get("message", ""),
message=message,
context=call.context,
)
except ValueError as err:
raise ServiceValidationError(str(err)) from err
@@ -1075,7 +1120,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_COMPLETE_CHORE,
handle_complete_chore,
_audited(handle_complete_chore),
schema=vol.Schema(
{
vol.Required(ATTR_CHORE_ID): cv.string,
@@ -1089,7 +1134,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_COMPLETE_BONUS_SUBTASK,
_safe(handle_complete_bonus_subtask),
_audited(handle_complete_bonus_subtask),
schema=vol.Schema(
{
vol.Required(ATTR_CHORE_ID): cv.string,
@@ -1102,7 +1147,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_START_TIMED_TASK,
_safe(handle_start_timed_task),
_audited(handle_start_timed_task),
schema=vol.Schema(
{
vol.Required(ATTR_CHORE_ID): cv.string,
@@ -1114,7 +1159,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_PAUSE_TIMED_TASK,
_safe(handle_pause_timed_task),
_audited(handle_pause_timed_task),
schema=vol.Schema(
{
vol.Required(ATTR_CHORE_ID): cv.string,
@@ -1126,7 +1171,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_STOP_TIMED_TASK,
_safe(handle_stop_timed_task),
_audited(handle_stop_timed_task),
schema=vol.Schema(
{
vol.Required(ATTR_CHORE_ID): cv.string,
@@ -1249,7 +1294,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_REQUEST_SWAP,
_safe(handle_request_swap),
_audited(handle_request_swap),
schema=vol.Schema(
{
vol.Required("chore_id"): cv.string,
@@ -1265,9 +1310,11 @@ async def _async_register_services(hass: HomeAssistant) -> None:
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,
# Blank means "use the configured default"; anything else has to
# be an entity in the right domain, not an arbitrary string.
vol.Optional("media_player", default=""): vol.Any("", cv.entity_domain("media_player")),
vol.Optional("tts_entity", default=""): vol.Any("", cv.entity_domain("tts")),
vol.Optional("message", default=""): vol.All(cv.string, vol.Length(max=500)),
}
),
)
@@ -1275,14 +1322,14 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_SPIN_ROULETTE,
_safe(handle_spin_roulette),
_audited(handle_spin_roulette),
schema=vol.Schema({vol.Required(ATTR_CHILD_ID): cv.string}),
)
hass.services.async_register(
DOMAIN,
SERVICE_CHOOSE_AVATAR,
_safe(handle_choose_avatar),
_audited(handle_choose_avatar),
schema=vol.Schema(
{
vol.Required(ATTR_CHILD_ID): cv.string,
@@ -1294,7 +1341,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_CLAIM_REWARD,
_safe(handle_claim_reward),
_audited(handle_claim_reward),
schema=vol.Schema(
{
vol.Required(ATTR_REWARD_ID): cv.string,
@@ -1324,7 +1371,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_ALLOCATE_POINTS_TO_POOL,
_safe(handle_allocate_points_to_pool),
_audited(handle_allocate_points_to_pool),
schema=vol.Schema(
{
vol.Required(ATTR_CHILD_ID): cv.string,
@@ -1366,7 +1413,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
_safe(handle_preview_sound),
schema=vol.Schema(
{
vol.Required(ATTR_SOUND): vol.In(COMPLETION_SOUND_OPTIONS),
vol.Required(ATTR_SOUND): vol.All(str, _valid_sound),
}
),
)