Skip to content
Draft
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
2 changes: 2 additions & 0 deletions coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ def __init__(
self.repeat_days_before_end: int = max(0, repeat_days_before_end or 0)
self.repeat_months_interval: int = max(1, repeat_months_interval or 1)
self.due_soon_days: int = max(0, due_soon_days or 0)
self.times_completed: int = 0
self._listeners: list[Callable[[], None]] = []

def async_add_listener(self, update_callback: Callable[[], None]) -> Callable[[], None]:
Expand Down Expand Up @@ -136,6 +137,7 @@ async def async_mark_as_done(self, today: date) -> None:
self.last_done = self._find_most_recent_occurrence(today)
else:
self.last_done = today
self.times_completed += 1
self._async_notify_listeners()

async def async_set_last_done_date(self, new_date: date) -> None:
Expand Down
52 changes: 50 additions & 2 deletions sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from typing import Any, Callable

from homeassistant.components.sensor import (
SensorEntity, RestoreSensor,
SensorEntity, RestoreSensor, SensorStateClass,
)
from homeassistant.const import CONF_NAME, CONF_ICON, CONF_ENTITY_ID, EVENT_STATE_CHANGED
from homeassistant.core import HomeAssistant, EventStateChangedData, callback
Expand Down Expand Up @@ -49,7 +49,8 @@ async def async_setup_entry(
options.get(CONF_ACTIVE_OVERRIDE),
options.get(CONF_TASK_INTERVAL_OVERRIDE),
options.get(CONF_DUE_SOON_OVERRIDE),
options.get(CONF_DEPENDENCIES) or [])])
options.get(CONF_DEPENDENCIES) or []),
TaskTrackerTimesCompletedSensor(coordinator, data[CONF_NAME], entry.entry_id, hass)])


class TaskTrackerSensor(RestoreSensor, SensorEntity):
Expand Down Expand Up @@ -440,3 +441,50 @@ async def async_mark_as_done(self) -> None:
async def async_set_last_done_date(self, new_date: date) -> None:
"""Set the last done date."""
await self.coordinator.async_set_last_done_date(new_date)


class TaskTrackerTimesCompletedSensor(RestoreSensor, SensorEntity):
"""Sensor tracking how many times a task was completed."""

_attr_has_entity_name = True
_attr_should_poll = False
_attr_name = None
_attr_translation_key = "times_completed"
_attr_state_class = SensorStateClass.TOTAL_INCREASING

def __init__(self, coordinator: TaskTrackerCoordinator, entry_name: str, entry_id: str, hass: HomeAssistant) -> None:
"""Initialize the times-completed sensor."""
self.coordinator = coordinator
self.entry_id = entry_id
device_id = f"{DOMAIN}_{self.entry_id}"
self._attr_unique_id = f"{entry_id}_times_completed"
self.entity_id = generate_entity_id("sensor.task_tracker_{}_times_completed", slugify(entry_name), hass=hass)
self._attr_native_value = 0
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device_id)},
manufacturer="Gensyn",
model="Task Tracker",
name=entry_name,
)

async def async_added_to_hass(self) -> None:
"""Restore last known state on startup."""
await super().async_added_to_hass()
self.async_on_remove(
self.coordinator.async_add_listener(
lambda: self.async_schedule_update_ha_state(force_refresh=True)
)
)
last_sensor_state = await self.async_get_last_sensor_data()
if last_sensor_state is not None:
try:
restored_times_completed = int(last_sensor_state.native_value)
except (TypeError, ValueError):
restored_times_completed = 0
self.coordinator.times_completed = max(self.coordinator.times_completed, restored_times_completed)
self._attr_native_value = self.coordinator.times_completed
self.async_write_ha_state()

async def async_update(self) -> None:
"""Update sensor state from coordinator."""
self._attr_native_value = self.coordinator.times_completed
3 changes: 3 additions & 0 deletions strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,9 @@
},
"entity": {
"sensor": {
"times_completed": {
"name": "Times completed"
},
"status": {
"state": {
"done": "Done",
Expand Down
54 changes: 54 additions & 0 deletions tests/integration_tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,15 @@ async def test_button_state_created(self, hass: HomeAssistant) -> None:
state = hass.states.get("button.task_tracker_water_plants_mark_as_done")
assert state is not None

async def test_times_completed_sensor_created(self, hass: HomeAssistant) -> None:
entry = _make_entry()
await _setup_entry(hass, entry)

state = hass.states.get("sensor.task_tracker_water_plants_times_completed")
assert state is not None
assert state.state == "0"
assert state.attributes["state_class"] == "total_increasing"

async def test_sensor_initial_state_is_due(self, hass: HomeAssistant) -> None:
"""With last_done at the epoch the task is always overdue → state is 'due'."""
entry = _make_entry()
Expand Down Expand Up @@ -218,6 +227,21 @@ async def test_last_done_attribute_updated_to_today(self, hass: HomeAssistant) -
state = hass.states.get("sensor.task_tracker_water_plants")
assert state.attributes["last_done"] == str(dt_util.now().date())

async def test_service_increments_times_completed(self, hass: HomeAssistant) -> None:
entry = _make_entry()
await _setup_entry(hass, entry)

await hass.services.async_call(
DOMAIN,
SERVICE_MARK_AS_DONE,
{"entity_id": "sensor.task_tracker_water_plants"},
blocking=True,
)
await hass.async_block_till_done()

state = hass.states.get("sensor.task_tracker_water_plants_times_completed")
assert state.state == "1"


# ---------------------------------------------------------------------------
# set_last_done_date service
Expand Down Expand Up @@ -266,6 +290,21 @@ async def test_service_recalculates_due_date(self, hass: HomeAssistant) -> None:
state = hass.states.get("sensor.task_tracker_water_plants")
assert state.attributes["due_date"] == "2024-06-22"

async def test_service_does_not_increment_times_completed(self, hass: HomeAssistant) -> None:
entry = _make_entry()
await _setup_entry(hass, entry)

await hass.services.async_call(
DOMAIN,
SERVICE_SET_LAST_DONE_DATE,
{"entity_id": "sensor.task_tracker_water_plants", "date": date(2024, 6, 15)},
blocking=True,
)
await hass.async_block_till_done()

state = hass.states.get("sensor.task_tracker_water_plants_times_completed")
assert state.state == "0"


# ---------------------------------------------------------------------------
# Button press
Expand Down Expand Up @@ -306,6 +345,21 @@ async def test_button_press_updates_coordinator_last_done(self, hass: HomeAssist
coordinator = hass.data[DOMAIN]["e1"]
assert coordinator.last_done == dt_util.now().date()

async def test_button_press_increments_times_completed(self, hass: HomeAssistant) -> None:
entry = _make_entry()
await _setup_entry(hass, entry)

await hass.services.async_call(
"button",
"press",
{"entity_id": "button.task_tracker_water_plants_mark_as_done"},
blocking=True,
)
await hass.async_block_till_done()

state = hass.states.get("sensor.task_tracker_water_plants_times_completed")
assert state.state == "1"


# ---------------------------------------------------------------------------
# Inactive task
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,7 @@ async def async_get_last_sensor_data(self):

async def async_get_last_state(self):
return None


class SensorStateClass:
TOTAL_INCREASING = "total_increasing"
26 changes: 25 additions & 1 deletion tests/unit_tests/test_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
sys.path.insert(0, absolute_plugin_path)

from task_tracker.coordinator import TaskTrackerCoordinator
from task_tracker.sensor import TaskTrackerSensor
from task_tracker.sensor import TaskTrackerSensor, TaskTrackerTimesCompletedSensor
from task_tracker.const import (
CONF_DAY, CONF_WEEK, CONF_MONTH, CONF_YEAR,
CONST_DUE, CONST_DUE_SOON, CONST_DONE, CONST_INACTIVE,
Expand Down Expand Up @@ -235,6 +235,30 @@ async def test_sync_called_for_each_todo_list(self):
mock_sync.assert_any_call("todo.list2")


class TestTaskTrackerTimesCompletedSensor(unittest.IsolatedAsyncioTestCase):

def test_uses_translation_key_for_name(self):
sensor = TaskTrackerTimesCompletedSensor(make_sensor().coordinator, "My Task", "abc123", MagicMock())
self.assertIsNone(sensor._attr_name)
self.assertEqual(sensor._attr_translation_key, "times_completed")

def test_entity_id_uses_times_completed_suffix(self):
sensor = TaskTrackerTimesCompletedSensor(make_sensor().coordinator, "My Task", "abc123", MagicMock())
self.assertIn("times_completed", sensor.entity_id)

async def test_async_added_to_hass_writes_restored_state(self):
sensor = TaskTrackerTimesCompletedSensor(make_sensor().coordinator, "My Task", "abc123", MagicMock())
sensor.async_on_remove = MagicMock()
restored_state = MagicMock(native_value="3")

with patch.object(sensor, "async_get_last_sensor_data", new_callable=AsyncMock, return_value=restored_state):
with patch.object(sensor, "async_write_ha_state") as mock_write_ha_state:
await sensor.async_added_to_hass()

self.assertEqual(sensor._attr_native_value, 3)
mock_write_ha_state.assert_called_once()


class TestTaskTrackerSensorFilterStateChanges(unittest.TestCase):

def _make_event_data(self, entity_id, old_state, new_state):
Expand Down
3 changes: 3 additions & 0 deletions translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,9 @@
},
"entity": {
"sensor": {
"times_completed": {
"name": "Anzahl Abschlüsse"
},
"status": {
"state": {
"done": "Erledigt",
Expand Down
3 changes: 3 additions & 0 deletions translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,9 @@
},
"entity": {
"sensor": {
"times_completed": {
"name": "Times completed"
},
"status": {
"state": {
"done": "Done",
Expand Down
3 changes: 3 additions & 0 deletions translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,9 @@
},
"entity": {
"sensor": {
"times_completed": {
"name": "Nombre d'accomplissements"
},
"status": {
"state": {
"done": "Fait",
Expand Down