Create/delete lists at runtime in Bring integration (#130098)

This commit is contained in:
Manu 2025-01-31 13:23:44 +01:00 committed by GitHub
parent 8eb9cc0e8e
commit 0773e37dab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 266 additions and 43 deletions

View File

@ -19,6 +19,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL from homeassistant.const import CONF_EMAIL
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN from .const import DOMAIN
@ -39,6 +40,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
config_entry: ConfigEntry config_entry: ConfigEntry
user_settings: BringUserSettingsResponse user_settings: BringUserSettingsResponse
lists: list[BringList]
def __init__(self, hass: HomeAssistant, bring: Bring) -> None: def __init__(self, hass: HomeAssistant, bring: Bring) -> None:
"""Initialize the Bring data coordinator.""" """Initialize the Bring data coordinator."""
@ -49,10 +51,13 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
update_interval=timedelta(seconds=90), update_interval=timedelta(seconds=90),
) )
self.bring = bring self.bring = bring
self.previous_lists: set[str] = set()
async def _async_update_data(self) -> dict[str, BringData]: async def _async_update_data(self) -> dict[str, BringData]:
"""Fetch the latest data from bring."""
try: try:
lists_response = await self.bring.load_lists() self.lists = (await self.bring.load_lists()).lists
except BringRequestException as e: except BringRequestException as e:
raise UpdateFailed("Unable to connect and retrieve data from bring") from e raise UpdateFailed("Unable to connect and retrieve data from bring") from e
except BringParseException as e: except BringParseException as e:
@ -72,8 +77,14 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
) from exc ) from exc
return self.data return self.data
if self.previous_lists - (
current_lists := {lst.listUuid for lst in self.lists}
):
self._purge_deleted_lists()
self.previous_lists = current_lists
list_dict: dict[str, BringData] = {} list_dict: dict[str, BringData] = {}
for lst in lists_response.lists: for lst in self.lists:
if (ctx := set(self.async_contexts())) and lst.listUuid not in ctx: if (ctx := set(self.async_contexts())) and lst.listUuid not in ctx:
continue continue
try: try:
@ -95,6 +106,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
try: try:
await self.bring.login() await self.bring.login()
self.user_settings = await self.bring.get_all_user_settings() self.user_settings = await self.bring.get_all_user_settings()
self.lists = (await self.bring.load_lists()).lists
except BringRequestException as e: except BringRequestException as e:
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
translation_domain=DOMAIN, translation_domain=DOMAIN,
@ -111,3 +123,21 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
translation_key="setup_authentication_exception", translation_key="setup_authentication_exception",
translation_placeholders={CONF_EMAIL: self.bring.mail}, translation_placeholders={CONF_EMAIL: self.bring.mail},
) from e ) from e
self._purge_deleted_lists()
def _purge_deleted_lists(self) -> None:
"""Purge device entries of deleted lists."""
device_reg = dr.async_get(self.hass)
identifiers = {
(DOMAIN, f"{self.config_entry.unique_id}_{lst.listUuid}")
for lst in self.lists
}
for device in dr.async_entries_for_config_entry(
device_reg, self.config_entry.entry_id
):
if not set(device.identifiers) & identifiers:
_LOGGER.debug("Removing obsolete device entry %s", device.name)
device_reg.async_update_device(
device.id, remove_config_entry_id=self.config_entry.entry_id
)

View File

@ -2,11 +2,13 @@
from __future__ import annotations from __future__ import annotations
from bring_api.types import BringList
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN from .const import DOMAIN
from .coordinator import BringData, BringDataUpdateCoordinator from .coordinator import BringDataUpdateCoordinator
class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]):
@ -17,20 +19,20 @@ class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]):
def __init__( def __init__(
self, self,
coordinator: BringDataUpdateCoordinator, coordinator: BringDataUpdateCoordinator,
bring_list: BringData, bring_list: BringList,
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""
super().__init__(coordinator, bring_list.lst.listUuid) super().__init__(coordinator, bring_list.listUuid)
self._list_uuid = bring_list.lst.listUuid self._list_uuid = bring_list.listUuid
self.device_info = DeviceInfo( self.device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE, entry_type=DeviceEntryType.SERVICE,
name=bring_list.lst.name, name=bring_list.name,
identifiers={ identifiers={
(DOMAIN, f"{coordinator.config_entry.unique_id}_{self._list_uuid}") (DOMAIN, f"{coordinator.config_entry.unique_id}_{self._list_uuid}")
}, },
manufacturer="Bring! Labs AG", manufacturer="Bring! Labs AG",
model="Bring! Grocery Shopping List", model="Bring! Grocery Shopping List",
configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.data.keys()).index(self._list_uuid)}", configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.lists).index(bring_list)}",
) )

View File

@ -53,7 +53,7 @@ rules:
docs-supported-functions: todo docs-supported-functions: todo
docs-troubleshooting: todo docs-troubleshooting: todo
docs-use-cases: todo docs-use-cases: todo
dynamic-devices: todo dynamic-devices: done
entity-category: done entity-category: done
entity-device-class: done entity-device-class: done
entity-disabled-by-default: done entity-disabled-by-default: done
@ -65,7 +65,7 @@ rules:
status: exempt status: exempt
comment: | comment: |
no repairs no repairs
stale-devices: todo stale-devices: done
# Platinum # Platinum
async-dependency: done async-dependency: done
inject-websession: done inject-websession: done

View File

@ -8,6 +8,7 @@ from enum import StrEnum
from bring_api import BringUserSettingsResponse from bring_api import BringUserSettingsResponse
from bring_api.const import BRING_SUPPORTED_LOCALES from bring_api.const import BRING_SUPPORTED_LOCALES
from bring_api.types import BringList
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
@ -15,7 +16,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription, SensorEntityDescription,
) )
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
@ -90,16 +91,28 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the sensor platform.""" """Set up the sensor platform."""
coordinator = config_entry.runtime_data coordinator = config_entry.runtime_data
lists_added: set[str] = set()
async_add_entities( @callback
BringSensorEntity( def add_entities() -> None:
coordinator, """Add sensor entities."""
bring_list, nonlocal lists_added
description,
) if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added:
for description in SENSOR_DESCRIPTIONS async_add_entities(
for bring_list in coordinator.data.values() BringSensorEntity(
) coordinator,
bring_list,
description,
)
for description in SENSOR_DESCRIPTIONS
for bring_list in coordinator.lists
if bring_list.listUuid in new_lists
)
lists_added |= new_lists
coordinator.async_add_listener(add_entities)
add_entities()
class BringSensorEntity(BringBaseEntity, SensorEntity): class BringSensorEntity(BringBaseEntity, SensorEntity):
@ -110,7 +123,7 @@ class BringSensorEntity(BringBaseEntity, SensorEntity):
def __init__( def __init__(
self, self,
coordinator: BringDataUpdateCoordinator, coordinator: BringDataUpdateCoordinator,
bring_list: BringData, bring_list: BringList,
entity_description: BringSensorEntityDescription, entity_description: BringSensorEntityDescription,
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""

View File

@ -12,6 +12,7 @@ from bring_api import (
BringNotificationType, BringNotificationType,
BringRequestException, BringRequestException,
) )
from bring_api.types import BringList
import voluptuous as vol import voluptuous as vol
from homeassistant.components.todo import ( from homeassistant.components.todo import (
@ -20,7 +21,7 @@ from homeassistant.components.todo import (
TodoListEntity, TodoListEntity,
TodoListEntityFeature, TodoListEntityFeature,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -45,14 +46,23 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the sensor from a config entry created in the integrations UI.""" """Set up the sensor from a config entry created in the integrations UI."""
coordinator = config_entry.runtime_data coordinator = config_entry.runtime_data
lists_added: set[str] = set()
async_add_entities( @callback
BringTodoListEntity( def add_entities() -> None:
coordinator, """Add or remove todo list entities."""
bring_list=bring_list, nonlocal lists_added
)
for bring_list in coordinator.data.values() if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added:
) async_add_entities(
BringTodoListEntity(coordinator, bring_list)
for bring_list in coordinator.lists
if bring_list.listUuid in new_lists
)
lists_added |= new_lists
coordinator.async_add_listener(add_entities)
add_entities()
platform = entity_platform.async_get_current_platform() platform = entity_platform.async_get_current_platform()
@ -81,7 +91,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity):
) )
def __init__( def __init__(
self, coordinator: BringDataUpdateCoordinator, bring_list: BringData self, coordinator: BringDataUpdateCoordinator, bring_list: BringList
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""
super().__init__(coordinator, bring_list) super().__init__(coordinator, bring_list)

View File

@ -1,5 +1,5 @@
{ {
"uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", "uuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5",
"status": "REGISTERED", "status": "REGISTERED",
"items": { "items": {
"purchase": [ "purchase": [

View File

@ -0,0 +1,46 @@
{
"uuid": "b4776778-7f6c-496e-951b-92a35d3db0dd",
"status": "REGISTERED",
"items": {
"purchase": [
{
"uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de",
"itemId": "Paprika",
"specification": "Rot",
"attributes": [
{
"type": "PURCHASE_CONDITIONS",
"content": {
"urgent": true,
"convenient": true,
"discounted": true
}
}
]
},
{
"uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e",
"itemId": "Pouletbrüstli",
"specification": "Bio",
"attributes": [
{
"type": "PURCHASE_CONDITIONS",
"content": {
"urgent": true,
"convenient": true,
"discounted": true
}
}
]
}
],
"recently": [
{
"uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954",
"itemId": "Ananas",
"specification": "",
"attributes": []
}
]
}
}

View File

@ -1,5 +1,5 @@
{ {
"uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", "uuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5",
"status": "INVITATION", "status": "INVITATION",
"items": { "items": {
"purchase": [ "purchase": [

View File

@ -1,5 +1,5 @@
{ {
"uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", "uuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5",
"status": "SHARED", "status": "SHARED",
"items": { "items": {
"purchase": [ "purchase": [

View File

@ -0,0 +1,9 @@
{
"lists": [
{
"listUuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5",
"name": "Einkauf",
"theme": "ch.publisheria.bring.theme.home"
}
]
}

View File

@ -47,7 +47,7 @@
]), ]),
}), }),
'status': 'REGISTERED', 'status': 'REGISTERED',
'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', 'uuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd',
}), }),
'lst': dict({ 'lst': dict({
'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd',
@ -101,7 +101,7 @@
]), ]),
}), }),
'status': 'REGISTERED', 'status': 'REGISTERED',
'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', 'uuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5',
}), }),
'lst': dict({ 'lst': dict({
'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5',

View File

@ -1,11 +1,15 @@
"""Test for diagnostics platform of the Bring! integration.""" """Test for diagnostics platform of the Bring! integration."""
from unittest.mock import AsyncMock
from bring_api import BringItemsResponse
import pytest import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from homeassistant.components.bring.const import DOMAIN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry from tests.common import MockConfigEntry, load_fixture
from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator from tests.typing import ClientSessionGenerator
@ -16,8 +20,13 @@ async def test_diagnostics(
hass_client: ClientSessionGenerator, hass_client: ClientSessionGenerator,
bring_config_entry: MockConfigEntry, bring_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
mock_bring_client: AsyncMock,
) -> None: ) -> None:
"""Test diagnostics.""" """Test diagnostics."""
mock_bring_client.get_list.side_effect = [
BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)),
BringItemsResponse.from_json(load_fixture("items2.json", DOMAIN)),
]
bring_config_entry.add_to_hass(hass) bring_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.config_entries.async_setup(bring_config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()

View File

@ -3,7 +3,12 @@
from datetime import timedelta from datetime import timedelta
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from bring_api import BringAuthException, BringParseException, BringRequestException from bring_api import (
BringAuthException,
BringListResponse,
BringParseException,
BringRequestException,
)
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
@ -16,7 +21,7 @@ from homeassistant.helpers import device_registry as dr
from .conftest import UUID from .conftest import UUID
from tests.common import MockConfigEntry, async_fire_time_changed from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture
async def setup_integration( async def setup_integration(
@ -115,6 +120,25 @@ async def test_config_entry_not_ready(
assert bring_config_entry.state is ConfigEntryState.SETUP_RETRY assert bring_config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.parametrize("exception", [BringRequestException, BringParseException])
async def test_config_entry_not_ready_udpdate_failed(
hass: HomeAssistant,
bring_config_entry: MockConfigEntry,
mock_bring_client: AsyncMock,
exception: Exception,
) -> None:
"""Test config entry not ready from update failed in _async_update_data."""
mock_bring_client.load_lists.side_effect = [
mock_bring_client.load_lists.return_value,
exception,
]
bring_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(bring_config_entry.entry_id)
await hass.async_block_till_done()
assert bring_config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.parametrize( @pytest.mark.parametrize(
("exception", "state"), ("exception", "state"),
[ [
@ -133,7 +157,10 @@ async def test_config_entry_not_ready_auth_error(
) -> None: ) -> None:
"""Test config entry not ready from authentication error.""" """Test config entry not ready from authentication error."""
mock_bring_client.load_lists.side_effect = BringAuthException mock_bring_client.load_lists.side_effect = [
mock_bring_client.load_lists.return_value,
BringAuthException,
]
mock_bring_client.retrieve_new_access_token.side_effect = exception mock_bring_client.retrieve_new_access_token.side_effect = exception
bring_config_entry.add_to_hass(hass) bring_config_entry.add_to_hass(hass)
@ -170,3 +197,71 @@ async def test_coordinator_skips_deactivated(
await hass.async_block_till_done() await hass.async_block_till_done()
assert mock_bring_client.get_list.await_count == 1 assert mock_bring_client.get_list.await_count == 1
async def test_purge_devices(
hass: HomeAssistant,
bring_config_entry: MockConfigEntry,
mock_bring_client: AsyncMock,
device_registry: dr.DeviceRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test removing device entry of deleted list."""
list_uuid = "b4776778-7f6c-496e-951b-92a35d3db0dd"
await setup_integration(hass, bring_config_entry)
assert bring_config_entry.state is ConfigEntryState.LOADED
assert device_registry.async_get_device(
{(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")}
)
mock_bring_client.load_lists.return_value = BringListResponse.from_json(
load_fixture("lists2.json", DOMAIN)
)
freezer.tick(timedelta(seconds=90))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (
device_registry.async_get_device(
{(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")}
)
is None
)
async def test_create_devices(
hass: HomeAssistant,
bring_config_entry: MockConfigEntry,
mock_bring_client: AsyncMock,
device_registry: dr.DeviceRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test create device entry for new lists."""
list_uuid = "b4776778-7f6c-496e-951b-92a35d3db0dd"
mock_bring_client.load_lists.return_value = BringListResponse.from_json(
load_fixture("lists2.json", DOMAIN)
)
await setup_integration(hass, bring_config_entry)
assert bring_config_entry.state is ConfigEntryState.LOADED
assert (
device_registry.async_get_device(
{(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")}
)
is None
)
mock_bring_client.load_lists.return_value = BringListResponse.from_json(
load_fixture("lists.json", DOMAIN)
)
freezer.tick(timedelta(seconds=90))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert device_registry.async_get_device(
{(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")}
)

View File

@ -26,15 +26,19 @@ def sensor_only() -> Generator[None]:
yield yield
@pytest.mark.usefixtures("mock_bring_client")
async def test_setup( async def test_setup(
hass: HomeAssistant, hass: HomeAssistant,
bring_config_entry: MockConfigEntry, bring_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
mock_bring_client: AsyncMock,
) -> None: ) -> None:
"""Snapshot test states of sensor platform.""" """Snapshot test states of sensor platform."""
mock_bring_client.get_list.side_effect = [
BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)),
BringItemsResponse.from_json(load_fixture("items2.json", DOMAIN)),
]
bring_config_entry.add_to_hass(hass) bring_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.config_entries.async_setup(bring_config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()

View File

@ -4,10 +4,11 @@ from collections.abc import Generator
import re import re
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from bring_api import BringItemOperation, BringRequestException from bring_api import BringItemOperation, BringItemsResponse, BringRequestException
import pytest import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from homeassistant.components.bring.const import DOMAIN
from homeassistant.components.todo import ( from homeassistant.components.todo import (
ATTR_DESCRIPTION, ATTR_DESCRIPTION,
ATTR_ITEM, ATTR_ITEM,
@ -21,7 +22,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform from tests.common import MockConfigEntry, load_fixture, snapshot_platform
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -40,9 +41,13 @@ async def test_todo(
bring_config_entry: MockConfigEntry, bring_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
mock_bring_client: AsyncMock,
) -> None: ) -> None:
"""Snapshot test states of todo platform.""" """Snapshot test states of todo platform."""
mock_bring_client.get_list.side_effect = [
BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)),
BringItemsResponse.from_json(load_fixture("items2.json", DOMAIN)),
]
bring_config_entry.add_to_hass(hass) bring_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.config_entries.async_setup(bring_config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()