Add the per-child next-quest sensor
This commit was merged in pull request #107.
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user