Add the per-child next-quest sensor

This commit was merged in pull request #107.
This commit is contained in:
2026-09-19 11:55:20 -04:00
2 changed files with 159 additions and 7 deletions
+91 -2
View File
@@ -98,6 +98,40 @@ def _instance_payload(
return payload return payload
#: State reported by the next-quest sensor when the child owes nothing
#: else today. A documented sentinel, never an empty string (an empty
#: state reads as "sensor went unavailable" in HA).
NEXT_QUEST_NONE_STATE = "none"
def _next_open_quest(
child: ChildDaySnapshot,
) -> QuestInstanceView | None:
"""Return the child's earliest open quest from today's snapshot.
The snapshot carries TODAY's instances, so "next" is the earliest
open instance by (due_date, due_time), with ``due_time`` ordering
ahead of ``None`` (a window with no due time comes first only
within the same date — the materializer snapshots each window's
due_time, so a missing one means the window is unscheduled).
Missed and completed instances never qualify.
"""
open_views = [
view
for view in child.instances
if view.state == "open"
]
if not open_views:
return None
return min(
open_views,
key=lambda view: (
view.due_date,
view.due_time if view.due_time is not None else "",
),
)
class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity): class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity):
"""Base for the per-child day sensors. """Base for the per-child day sensors.
@@ -268,15 +302,70 @@ class NestQuestCompletionPctTodaySensor(_NestQuestChildDaySensor):
return _state_within_limit(child.completion_pct) return _state_within_limit(child.completion_pct)
class NestQuestNextQuestSensor(_NestQuestChildDaySensor):
"""The child's next open quest today (title in the state).
State is the quest TITLE, truncated to HA's 255-character state
limit (attributes always carry the full title); when the child
owes nothing else today the state is the documented sentinel
``none`` (NEXT_QUEST_NONE_STATE), never an empty string.
"""
_attr_state_class = None # a label, not a measurement
_name_suffix = "next quest"
def __init__(
self,
coordinator: NestQuestCoordinator,
child: ChildDaySnapshot,
) -> None:
super().__init__(coordinator, child)
self._attr_unique_id = (
f"{DOMAIN}_child_{child.child_id}_next_quest"
)
@property
def native_value(self) -> str | None:
child = self._child
if child is None:
return None
quest = _next_open_quest(child)
if quest is None:
return NEXT_QUEST_NONE_STATE
return quest.title[:MAX_STATE_LENGTH]
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
child = self._child
if child is None:
return None
quest = _next_open_quest(child)
if quest is None:
return {"child_id": child.child_id, "child_name": child.child_name}
return {
"instance_id": quest.instance_id,
"definition_id": quest.definition_id,
"child_id": quest.child_id,
"child_name": child.child_name,
"title": quest.title,
"icon": quest.icon,
"window": quest.window,
"due_date": quest.due_date,
"due_time": quest.due_time,
"overdue": quest.overdue,
}
def _child_day_sensors( def _child_day_sensors(
coordinator: NestQuestCoordinator, child: ChildDaySnapshot coordinator: NestQuestCoordinator, child: ChildDaySnapshot
) -> tuple[_NestQuestChildDaySensor, ...]: ) -> tuple[_NestQuestChildDaySensor, ...]:
"""Build the four documented day sensors for one active child.""" """Build the per-child day sensors for one active child."""
return ( return (
NestQuestQuestsDueTodaySensor(coordinator, child), NestQuestQuestsDueTodaySensor(coordinator, child),
NestQuestQuestsCompletedTodaySensor(coordinator, child), NestQuestQuestsCompletedTodaySensor(coordinator, child),
NestQuestQuestsRemainingTodaySensor(coordinator, child), NestQuestQuestsRemainingTodaySensor(coordinator, child),
NestQuestCompletionPctTodaySensor(coordinator, child), NestQuestCompletionPctTodaySensor(coordinator, child),
NestQuestNextQuestSensor(coordinator, child),
) )
@@ -285,7 +374,7 @@ async def async_setup_entry(
entry: ConfigEntry, entry: ConfigEntry,
async_add_entities: Callable[..., None], async_add_entities: Callable[..., None],
) -> None: ) -> None:
"""Create the four day sensors per active child. """Create the five day sensors per active child.
Entities are created for the snapshot's children at platform Entities are created for the snapshot's children at platform
setup; a coordinator listener adds sensors for children that setup; a coordinator listener adds sensors for children that
+68 -5
View File
@@ -97,7 +97,7 @@ async def _complete_first_instance(database, coordinator, child) -> None:
) )
async def test_every_active_child_gets_four_sensors_with_documented_identity( async def test_every_active_child_gets_five_sensors_with_documented_identity(
hass, make_entry hass, make_entry
) -> None: ) -> None:
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
@@ -109,8 +109,8 @@ async def test_every_active_child_gets_four_sensors_with_documented_identity(
await coordinator.async_refresh() await coordinator.async_refresh()
# Two quest children plus the zero-quest child: three children x # Two quest children plus the zero-quest child: three children x
# four sensors, no duplicates. # five sensors, no duplicates.
assert len(hass.entities) == 12 assert len(hass.entities) == 15
expected = { expected = {
ada.id: ("Ada", 1, 1, 0, 100), ada.id: ("Ada", 1, 1, 0, 100),
bo.id: ("Bo", 1, 0, 1, 0), bo.id: ("Bo", 1, 0, 1, 0),
@@ -121,6 +121,7 @@ async def test_every_active_child_gets_four_sensors_with_documented_identity(
completed_entity = _sensor(hass, child_id, "quests_completed_today") completed_entity = _sensor(hass, child_id, "quests_completed_today")
remaining_entity = _sensor(hass, child_id, "quests_remaining_today") remaining_entity = _sensor(hass, child_id, "quests_remaining_today")
pct_entity = _sensor(hass, child_id, "completion_pct_today") pct_entity = _sensor(hass, child_id, "completion_pct_today")
next_entity = _sensor(hass, child_id, "next_quest")
assert due_entity.name == f"NestQuest {name} quests due today" assert due_entity.name == f"NestQuest {name} quests due today"
assert completed_entity.name == ( assert completed_entity.name == (
f"NestQuest {name} quests completed today" f"NestQuest {name} quests completed today"
@@ -323,7 +324,7 @@ async def test_repeated_refreshes_do_not_duplicate_entities(
) )
await coordinator.async_refresh() await coordinator.async_refresh()
await coordinator.async_refresh() await coordinator.async_refresh()
assert len(hass.entities) == 12 assert len(hass.entities) == 15
async def test_setup_with_no_children_registers_no_entities( async def test_setup_with_no_children_registers_no_entities(
@@ -382,4 +383,66 @@ async def test_failed_platform_unload_returns_false_and_retains_runtime(
hass.config_entries.async_unload_platforms = _refuse_unload hass.config_entries.async_unload_platforms = _refuse_unload
assert await async_unload_entry(hass, entry) is False assert await async_unload_entry(hass, entry) is False
assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data
assert database.connected is True assert database.connected is True
async def test_next_quest_sensor_reports_earliest_open_quest(
hass, make_entry
) -> None:
"""The next-quest sensor carries the earliest open quest's title in
state with the full shape in attributes."""
entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry(
hass, make_entry
)
next_entity = _sensor(hass, ada.id, "next_quest")
assert next_entity.name == f"NestQuest Ada next quest"
assert next_entity.native_value == "Brush teeth"
attributes = next_entity.extra_state_attributes
assert attributes["title"] == "Brush teeth"
assert attributes["window"] == "morning"
assert attributes["instance_id"] == _snapshot_child(
coordinator, ada.id
).instances[0].instance_id
assert attributes["child_id"] == ada.id
async def test_next_quest_sensor_reports_none_sentinel_when_day_clear(
hass, make_entry
) -> None:
"""A child with every quest done (and a zero-quest child) reports
the documented sentinel, never an empty string."""
entry, coordinator, (ada, _bo, cory) = await _setup_seeded_entry(
hass, make_entry
)
database = entry.runtime_data.database
await _complete_first_instance(database, coordinator, ada)
await coordinator.async_refresh()
assert _sensor(hass, ada.id, "next_quest").native_value == "none"
assert _sensor(hass, cory.id, "next_quest").native_value == "none"
async def test_next_quest_state_truncates_over_the_limit(
hass, make_entry
) -> None:
"""A quest title longer than the state limit is truncated; the
attribute keeps the full title."""
entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry(
hass, make_entry
)
database = entry.runtime_data.database
from custom_components.nestquest.quest_definitions import (
edit_quest_definition,
)
snapshot_child = _snapshot_child(coordinator, ada.id)
await edit_quest_definition(
database,
snapshot_child.instances[0].definition_id,
title="A very long quest title. " * 30,
)
await coordinator.async_refresh()
next_entity = _sensor(hass, ada.id, "next_quest")
full_title = _snapshot_child(coordinator, ada.id).instances[0].title
assert len(full_title) > 255
state = next_entity.native_value
assert len(state) <= 255
assert next_entity.extra_state_attributes["title"] == full_title