Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions custom_components/taskmate/coord_assignments.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ def _refresh_tracked_availability_entities(self) -> None:
visibility.add(chore.weather_entity)
self._tracked_visibility_entities = visibility

# A child's photo is read from their picture entity, so a photo changed
# there has to reach the sensors without waiting for a TaskMate edit.
# Trimmed the same way child_avatar_image reads it, so a stray space in
# a stored id can't leave the photo untracked.
self._tracked_picture_entities = {
entity for c in self.storage.get_children() if (entity := (getattr(c, "picture_entity", "") or "").strip())
}

@callback
def _availability_state_changed(self, event: Any) -> None:
"""Cheap bus-filter: react only when a tracked entity changes.
Expand All @@ -66,6 +74,9 @@ def _availability_state_changed(self, event: Any) -> None:
if not entity_id:
return

if entity_id in getattr(self, "_tracked_picture_entities", set()):
self._picture_entity_changed(data)

is_availability = entity_id in getattr(self, "_tracked_availability_entities", set())
is_visibility = entity_id in getattr(self, "_tracked_visibility_entities", set())
if not (is_availability or is_visibility):
Expand All @@ -79,6 +90,22 @@ def _availability_state_changed(self, event: Any) -> None:
if is_availability:
self.hass.async_create_task(self._async_reevaluate_availability())

def _picture_entity_changed(self, data: dict) -> None:
"""Push a child's new photo out as soon as the entity's picture changes.

A person's state flips between home and away all day without touching
its picture, so only a real change of ``entity_picture`` (or the entity
appearing or vanishing) is worth invalidating the sensors for.
"""

def picture(state: Any) -> str:
return str((getattr(state, "attributes", None) or {}).get("entity_picture") or "") if state else ""

if picture(data.get("old_state")) == picture(data.get("new_state")):
return
self.external_state_version += 1
self.async_update_listeners()

async def _async_reevaluate_availability(self) -> None:
"""Re-run assignment for require_availability chores when availability flips.

Expand Down
29 changes: 29 additions & 0 deletions custom_components/taskmate/coord_avatars.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

import logging

from homeassistant.helpers import entity_registry as er

from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)

# Shipped defaults so the feature is useful out of the box. Parents can replace
Expand All @@ -36,6 +40,31 @@ def avatar_catalog(self) -> list[dict]:
return [a for a in raw if isinstance(a, dict) and a.get("icon")]
return list(DEFAULT_AVATAR_CATALOG)

def child_avatar_image(self, child) -> str:
"""The picture of the entity chosen for a child's face, or ``""``.

Read from that entity on every update rather than stored, so a photo
changed in HA appears here with nothing to re-save, and a deleted
entity simply falls back to the MDI avatar instead of a broken image.

Empty when no entity is chosen, it no longer exists, or it has no
picture — every call site then shows the MDI avatar, so a child is
never left with a blank face.

TaskMate's own child sensors are refused: they show this very photo,
so choosing one would make the face its own source and leave it stale.
"""
entity_id = (getattr(child, "picture_entity", "") or "").strip()
if not entity_id:
return ""
entry = er.async_get(self.hass).async_get(entity_id)
if entry is not None and entry.platform == DOMAIN:
return ""
state = self.hass.states.get(entity_id)
if state is None:
return ""
return str(state.attributes.get("entity_picture") or "")

def _avatar_unlocked(self, entry: dict, child) -> bool:
kind = entry.get("unlock_type", "free")
try:
Expand Down
2 changes: 2 additions & 0 deletions custom_components/taskmate/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,7 @@ async def async_add_child(
unavailability_entity: str = "",
pause_streak_when_unavailable: bool = False,
linked_user_id: str = "",
picture_entity: str = "",
birthday: str = "",
presence_entity: str = "",
age_group: str = "",
Expand All @@ -1004,6 +1005,7 @@ async def async_add_child(
unavailability_entity=unavailability_entity,
pause_streak_when_unavailable=pause_streak_when_unavailable,
linked_user_id=linked_user_id,
picture_entity=picture_entity,
birthday=birthday,
presence_entity=presence_entity,
age_group=age_group,
Expand Down
8 changes: 8 additions & 0 deletions custom_components/taskmate/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,12 @@ class Child:
total_penalties_received: int = 0
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
# Entity whose picture replaces the MDI avatar — normally the child's
# person.* entity. Deliberately independent of linked_user_id: a young
# child often has a person (with a photo) but no HA login of their own.
# Only the entity id is stored; the picture is read from it on every
# update, so a new photo set in HA shows up with nothing to re-save here.
picture_entity: str = ""
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
presence_entity: str = "" # person./device_tracker.; while not home, reminders wait for arrival (#926)
Expand Down Expand Up @@ -282,6 +288,7 @@ def from_dict(cls, data: dict[str, Any]) -> Child:
total_penalties_received=data.get("total_penalties_received", 0),
notify_service=data.get("notify_service"),
linked_user_id=data.get("linked_user_id", ""),
picture_entity=str(data.get("picture_entity", "") or ""),
quiet_hours_start=data.get("quiet_hours_start", ""),
quiet_hours_end=data.get("quiet_hours_end", ""),
presence_entity=str(data.get("presence_entity", "") or ""),
Expand Down Expand Up @@ -320,6 +327,7 @@ def to_dict(self) -> dict[str, Any]:
"total_penalties_received": self.total_penalties_received,
"notify_service": self.notify_service,
"linked_user_id": self.linked_user_id,
"picture_entity": self.picture_entity,
"quiet_hours_start": self.quiet_hours_start,
"quiet_hours_end": self.quiet_hours_end,
"presence_entity": self.presence_entity,
Expand Down
33 changes: 31 additions & 2 deletions custom_components/taskmate/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,11 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict:
"""
data = coordinator.data
data_id = id(data)
# External entities TaskMate reads (a child's photo among them) are not part
# of ``data``, so their version is part of the key.
external = getattr(coordinator, "external_state_version", 0)
cached = getattr(coordinator, _COMMON_CACHE_ATTR, None)
if cached and cached.get("data_id") == data_id:
if cached and cached.get("data_id") == data_id and cached.get("external") == external:
return cached["common"]

children = data.get("children", [])
Expand Down Expand Up @@ -142,10 +145,13 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict:
"pool_by_child_reward": pool_by_child_reward,
"pool_total_by_reward": pool_total_by_reward,
"total_allocated_by_child": total_allocated_by_child,
# Each child's photo (from their chosen picture entity), resolved once
# per update: every call site that draws a child's face reads this.
"child_avatar_images": {c.id: coordinator.child_avatar_image(c) for c in children},
# Sent back to redo by an inspection (#981): not "done" on the card.
"inspection_redo_ids": _inspection_redo_ids(coordinator),
}
setattr(coordinator, _COMMON_CACHE_ATTR, {"data_id": data_id, "common": common})
setattr(coordinator, _COMMON_CACHE_ATTR, {"data_id": data_id, "external": external, "common": common})
return common


Expand Down Expand Up @@ -217,6 +223,8 @@ def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> l
"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",
# The child's photo, or "" — cards fall back to `avatar`.
"avatar_image": common.get("child_avatar_images", {}).get(c.id, ""),
"last_completion_date": getattr(c, "last_completion_date", None),
"streak_paused": getattr(c, "streak_paused", False),
"on_vacation": coordinator._is_child_on_vacation(c),
Expand Down Expand Up @@ -698,6 +706,9 @@ def _build_pending_reward_claims(common: dict) -> list[dict]:
"reward_id": rc.reward_id,
"child_id": rc.child_id,
"child_name": child.name,
# No avatar_image here on purpose: the cards resolve a claim's
# face from the overview sensor's children[], and a copy per
# claim pushed this sensor past the 16KB recorder limit.
"child_avatar": getattr(child, "avatar", "mdi:account-circle") or "mdi:account-circle",
"reward_name": reward.name,
"reward_icon": reward.icon or "mdi:gift",
Expand Down Expand Up @@ -1575,6 +1586,7 @@ def extra_state_attributes(self) -> dict:
"child_id": child.id,
"child_name": child.name,
"avatar": child.avatar,
"avatar_image": self.coordinator.child_avatar_image(child),
"total_points_earned": child.total_points_earned,
"total_chores_completed": child.total_chores_completed,
"current_streak": child.current_streak,
Expand Down Expand Up @@ -1615,6 +1627,18 @@ def icon(self) -> str:
child = self.coordinator.get_child(self.child_id)
return child.avatar if child else "mdi:account-circle"

@property
def entity_picture(self) -> str | None:
"""The child's photo, so HA's own UI shows their face too.

HA prefers a picture over ``icon`` when both are set, so returning
None (the default) leaves the MDI avatar above in charge.
"""
child = self.coordinator.get_child(self.child_id)
if not child:
return None
return self.coordinator.child_avatar_image(child) or None

@property
def extra_state_attributes(self) -> dict:
"""Return additional attributes."""
Expand Down Expand Up @@ -1650,6 +1674,7 @@ def _included(c):
"child_id": child.id,
"child_name": child.name,
"avatar": child.avatar,
"avatar_image": self.coordinator.child_avatar_image(child),
"points": child.points,
"total_points_earned": child.total_points_earned,
"total_chores_completed": child.total_chores_completed,
Expand Down Expand Up @@ -1869,6 +1894,10 @@ def extra_state_attributes(self) -> dict:
earned.sort(key=lambda e: e.get("earned_at") or "", reverse=True)

return {
# Who the badges belong to, so the card can put a face to them.
"child_name": child.name,
"child_avatar": getattr(child, "avatar", "mdi:account-circle") or "mdi:account-circle",
"avatar_image": self.coordinator.child_avatar_image(child),
"earned": earned,
"available": available,
"total_badges": len(applicable),
Expand Down
5 changes: 5 additions & 0 deletions custom_components/taskmate/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,7 @@ async def _ws_get_state(hass, connection, msg, coordinator):
vol.Optional("unavailability_entity", default=""): str,
vol.Optional("pause_streak_when_unavailable", default=False): bool,
vol.Optional("linked_user_id", default=""): str,
vol.Optional("picture_entity", default=""): str,
vol.Optional("birthday", default=""): vol.All(str, vol.Length(max=10)),
vol.Optional("presence_entity", default=""): _validate_presence_entity,
vol.Optional("age_group", default=""): vol.In(("", *AGE_GROUPS)),
Expand All @@ -511,6 +512,7 @@ async def _ws_add_child(hass, connection, msg, coordinator):
unavailability_entity=_opt_str(msg.get("unavailability_entity")),
pause_streak_when_unavailable=bool(msg.get("pause_streak_when_unavailable", False)),
linked_user_id=_opt_str(msg.get("linked_user_id")),
picture_entity=_opt_str(msg.get("picture_entity")),
birthday=birthday,
presence_entity=msg.get("presence_entity", ""),
age_group=msg.get("age_group", ""),
Expand All @@ -529,6 +531,7 @@ async def _ws_add_child(hass, connection, msg, coordinator):
vol.Optional("unavailability_entity"): str,
vol.Optional("pause_streak_when_unavailable"): bool,
vol.Optional("linked_user_id"): str,
vol.Optional("picture_entity"): str,
vol.Optional("presence_entity"): _validate_presence_entity,
vol.Optional("is_guest"): bool,
vol.Optional("guest_expires_on"): str,
Expand Down Expand Up @@ -557,6 +560,8 @@ async def _ws_update_child(hass, connection, msg, coordinator):
existing.pause_streak_when_unavailable = bool(msg["pause_streak_when_unavailable"])
if "linked_user_id" in msg:
existing.linked_user_id = _opt_str(msg["linked_user_id"])
if "picture_entity" in msg:
existing.picture_entity = _opt_str(msg["picture_entity"])
if "birthday" in msg:
try:
existing.birthday = normalize_birthday(msg["birthday"])
Expand Down
2 changes: 2 additions & 0 deletions custom_components/taskmate/www/locales/da.json
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,8 @@
"panel.child_inverted": "(omvendt)",
"panel.child_link_user_label": "Knyt til Home Assistant-bruger (valgfrit)",
"panel.child_link_user_hint": "Når det er sat, kan kun denne Home Assistant-bruger (eller en admin) fuldføre pligter, indløse belønninger eller køre timere som dette barn via service-kald. Vælg \"Alle\" til en fælles familie-tablet.",
"panel.child_picture_entity_label": "Foto fra",
"panel.child_picture_entity_hint": "Vælg barnets person for at vise deres foto i stedet for ikonet ovenfor. Fotoet indstilles under Indstillinger › Personer; uden foto vises ikonet.",
"panel.child_kiosk_pin_label": "Kiosk-PIN-kode",
"panel.child_kiosk_pin_hint": "Valgfri. 4 cifre, som barnet taster på en delt kiosk-tablet. Gemmes som hash og kan ikke læses igen — lad feltet stå tomt for at beholde den nuværende kode.",
"panel.child_kiosk_pin_set": "Der er sat en PIN-kode.",
Expand Down
2 changes: 2 additions & 0 deletions custom_components/taskmate/www/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1980,6 +1980,8 @@
"panel.btn_clone_chore": "Duplizieren",
"panel.child_link_user_label": "Mit Home-Assistant-Benutzer verknüpfen (optional)",
"panel.child_link_user_hint": "Wenn gesetzt, kann nur dieser Home-Assistant-Benutzer (oder ein Admin) per Dienstaufruf Aufgaben erledigen, Belohnungen einlösen oder Timer für dieses Kind starten. Für ein gemeinsames Familien-Tablet auf \"Beliebig\" lassen.",
"panel.child_picture_entity_label": "Foto von",
"panel.child_picture_entity_hint": "Wähle die Person des Kindes, um ihr Foto anstelle des Symbols oben zu zeigen. Das Foto wird unter Einstellungen › Personen gesetzt; ohne Foto wird das Symbol angezeigt.",
"panel.child_kiosk_pin_label": "Kiosk-PIN",
"panel.child_kiosk_pin_hint": "Optional. 4 Ziffern, die das Kind auf einem gemeinsamen Kiosk-Tablet eintippt. Wird als Hash gespeichert und kann nicht ausgelesen werden — leer lassen, um die aktuelle PIN zu behalten.",
"panel.child_kiosk_pin_set": "Eine PIN ist gesetzt.",
Expand Down
2 changes: 2 additions & 0 deletions custom_components/taskmate/www/locales/en-GB.json
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,8 @@
"panel.child_inverted": "(inverted)",
"panel.child_link_user_label": "Link to Home Assistant user (optional)",
"panel.child_link_user_hint": "When set, only this Home Assistant user (or an admin) can complete chores, claim rewards, or run timers as this child via service calls. Leave as \"Anyone\" for a shared family tablet.",
"panel.child_picture_entity_label": "Photo from",
"panel.child_picture_entity_hint": "Pick the child's person to show their photo instead of the icon above. Set the photo under Settings › People; with none set, the icon is shown.",
"panel.child_kiosk_pin_label": "Kiosk PIN",
"panel.child_kiosk_pin_hint": "Optional. 4 digits this child types on a shared kiosk tablet. Stored hashed, so it can't be read back — leave blank to keep the current PIN.",
"panel.child_kiosk_pin_set": "A PIN is set.",
Expand Down
2 changes: 2 additions & 0 deletions custom_components/taskmate/www/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,8 @@
"panel.child_inverted": "(inverted)",
"panel.child_link_user_label": "Link to Home Assistant user (optional)",
"panel.child_link_user_hint": "When set, only this Home Assistant user (or an admin) can complete chores, claim rewards, or run timers as this child via service calls. Leave as \"Anyone\" for a shared family tablet.",
"panel.child_picture_entity_label": "Photo from",
"panel.child_picture_entity_hint": "Pick any entity with a picture, usually the child's person, to show it instead of the icon above. A person's photo is set under Settings › People; with no picture, the icon is shown.",
"panel.child_kiosk_pin_label": "Kiosk PIN",
"panel.child_kiosk_pin_hint": "Optional. 4 digits this child types on a shared kiosk tablet. Stored hashed, so it can't be read back — leave blank to keep the current PIN.",
"panel.child_kiosk_pin_set": "A PIN is set.",
Expand Down
2 changes: 2 additions & 0 deletions custom_components/taskmate/www/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -1980,6 +1980,8 @@
"panel.btn_clone_chore": "Dupliquer",
"panel.child_link_user_label": "Lier à un utilisateur Home Assistant (facultatif)",
"panel.child_link_user_hint": "Si défini, seul cet utilisateur Home Assistant (ou un admin) peut terminer des tâches, échanger des récompenses ou lancer des minuteurs pour cet enfant via les services. Laissez sur « N'importe qui » pour une tablette familiale partagée.",
"panel.child_picture_entity_label": "Photo de",
"panel.child_picture_entity_hint": "Choisissez la personne de l'enfant pour afficher sa photo à la place de l'icône ci-dessus. La photo se définit dans Paramètres › Personnes ; sans photo, l'icône est affichée.",
"panel.child_kiosk_pin_label": "Code PIN du kiosque",
"panel.child_kiosk_pin_hint": "Facultatif. 4 chiffres que l'enfant tape sur une tablette kiosque partagée. Stocké haché, il ne peut pas être relu — laissez vide pour garder le code actuel.",
"panel.child_kiosk_pin_set": "Un code PIN est défini.",
Expand Down
2 changes: 2 additions & 0 deletions custom_components/taskmate/www/locales/nb.json
Original file line number Diff line number Diff line change
Expand Up @@ -1980,6 +1980,8 @@
"panel.btn_clone_chore": "Dupliser",
"panel.child_link_user_label": "Koble til Home Assistant-bruker (valgfritt)",
"panel.child_link_user_hint": "Når satt, kan bare denne Home Assistant-brukeren (eller en admin) fullføre gjøremål, løse inn belønninger eller starte tidtakere for dette barnet via tjenestekall. La stå på «Hvem som helst» for et delt familiebrett.",
"panel.child_picture_entity_label": "Bilde fra",
"panel.child_picture_entity_hint": "Velg barnets person for å vise bildet i stedet for ikonet over. Bildet settes under Innstillinger › Personer; uten bilde vises ikonet.",
"panel.child_kiosk_pin_label": "Kiosk-PIN",
"panel.child_kiosk_pin_hint": "Valgfritt. 4 sifre barnet taster på et delt kiosk-nettbrett. Lagres som hash og kan ikke leses tilbake — la feltet stå tomt for å beholde gjeldende kode.",
"panel.child_kiosk_pin_set": "En PIN-kode er satt.",
Expand Down
2 changes: 2 additions & 0 deletions custom_components/taskmate/www/locales/nn.json
Original file line number Diff line number Diff line change
Expand Up @@ -1980,6 +1980,8 @@
"panel.btn_clone_chore": "Dupliser",
"panel.child_link_user_label": "Kople til Home Assistant-brukar (valfritt)",
"panel.child_link_user_hint": "Når sett, kan berre denne Home Assistant-brukaren (eller ein admin) fullføre gjeremål, løyse inn løningar eller starte tidtakarar for dette barnet via tenestekall. Lat stå på «Kven som helst» for eit delt familiebrett.",
"panel.child_picture_entity_label": "Bilete frå",
"panel.child_picture_entity_hint": "Vel personen til barnet for å vise biletet i staden for ikonet over. Biletet vert sett under Innstillingar › Personar; utan bilete vert ikonet vist.",
"panel.child_kiosk_pin_label": "Kiosk-PIN",
"panel.child_kiosk_pin_hint": "Valfritt. 4 siffer barnet tastar på eit delt kiosk-nettbrett. Vert lagra som hash og kan ikkje lesast tilbake — la feltet stå tomt for å halde på gjeldande kode.",
"panel.child_kiosk_pin_set": "Ein PIN-kode er sett.",
Expand Down
Loading
Loading