diff --git a/automations/update_delivery_times.yaml b/automations/update_delivery_times.yaml new file mode 100644 index 0000000..6d19edd --- /dev/null +++ b/automations/update_delivery_times.yaml @@ -0,0 +1,25 @@ +alias: Track Rohlik delivery ETA closely before delivery +description: >- + The delivery ETA of an upcoming order shifts a lot, so the regular 10-minute + refresh can be off right before the courier arrives. This automation polls + the lightweight update_delivery_times action every minute during the last + 30 minutes before the expected delivery, keeping the delivery time sensor + close to the real ETA. +mode: single +triggers: + - trigger: time_pattern + minutes: "/1" +conditions: + # Only poll when a delivery is expected within the next 30 minutes (also + # keeps polling up to 10 minutes past the ETA in case the courier is late). + # Replace sensor.rohlik_cz_delivery_time with your own Delivery Time sensor + # if it is named differently (e.g. with multiple accounts). + - condition: template + value_template: >- + {% set eta = states('sensor.rohlik_cz_delivery_time') %} + {{ eta not in ['unknown', 'unavailable'] + and -600 <= (as_datetime(eta) - now()).total_seconds() <= 1800 }} +actions: + - action: rohlikcz.update_delivery_times + data: + config_entry_id: XXXXXXXXXXXXXXXX # Replace with your Rohlik.cz config entry ID diff --git a/custom_components/rohlikcz/const.py b/custom_components/rohlikcz/const.py index 09ed0da..7c2d956 100644 --- a/custom_components/rohlikcz/const.py +++ b/custom_components/rohlikcz/const.py @@ -59,6 +59,7 @@ SERVICE_FETCH_ORDER_HISTORY = "fetch_order_history" SERVICE_ENRICH_ORDERS = "enrich_orders" SERVICE_REFRESH_SLOTS = "refresh_slots" +SERVICE_UPDATE_DELIVERY_TIMES = "update_delivery_times" """ Analytics options """ CONF_ANALYTICS = "analytics" diff --git a/custom_components/rohlikcz/hub.py b/custom_components/rohlikcz/hub.py index b08e94d..e77e285 100644 --- a/custom_components/rohlikcz/hub.py +++ b/custom_components/rohlikcz/hub.py @@ -751,6 +751,22 @@ async def refresh_slots(self) -> None: self.data["next_delivery_slot"] = result self.async_update_listeners() + async def refresh_delivery_times(self) -> None: + """Cheaply refresh only the delivery announcements (delivery-time ETA). + + The announcement carries the shifting delivery ETA for an upcoming + order, so this is light enough to poll every minute shortly before a + delivery. Updates self.data["delivery_announcements"] in place and + notifies entities, without disturbing the rest of the data or the + regular refresh cycle. + """ + if not self.data: + return + result = await self._client.delivery.get_announcements() + if result is not None: + self.data["delivery_announcements"] = result + self.async_update_listeners() + async def async_close(self) -> None: """Release resources held by the API client (called on unload).""" try: diff --git a/custom_components/rohlikcz/icons.json b/custom_components/rohlikcz/icons.json index a2ac3d8..82df06f 100644 --- a/custom_components/rohlikcz/icons.json +++ b/custom_components/rohlikcz/icons.json @@ -4,6 +4,7 @@ "search_product": {"service": "mdi:magnify"}, "get_shopping_list": {"service": "mdi:clipboard-list"}, "get_cart_content": {"service": "mdi:cart"}, - "search_and_add_to_cart": {"service": "mdi:cart-arrow-right"} + "search_and_add_to_cart": {"service": "mdi:cart-arrow-right"}, + "update_delivery_times": {"service": "mdi:truck-fast-outline"} } -} \ No newline at end of file +} diff --git a/custom_components/rohlikcz/manifest.json b/custom_components/rohlikcz/manifest.json index 329dfa1..79221bc 100644 --- a/custom_components/rohlikcz/manifest.json +++ b/custom_components/rohlikcz/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/dvejsada/HA-RohlikCZ/issues", "requirements": ["rohlik-api==0.2.0"], - "version": "0.6.0" + "version": "0.6.1" } diff --git a/custom_components/rohlikcz/services.py b/custom_components/rohlikcz/services.py index a6f322d..8f5be51 100644 --- a/custom_components/rohlikcz/services.py +++ b/custom_components/rohlikcz/services.py @@ -12,7 +12,7 @@ from .const import DOMAIN, ATTR_CONFIG_ENTRY_ID, ATTR_PRODUCT_ID, ATTR_QUANTITY, ATTR_PRODUCT_NAME, \ ATTR_SHOPPING_LIST_ID, ATTR_LIMIT, ATTR_FAVOURITE_ONLY, SERVICE_ADD_TO_CART, SERVICE_SEARCH_PRODUCT, SERVICE_GET_SHOPPING_LIST, \ SERVICE_GET_CART_CONTENT, SERVICE_SEARCH_AND_ADD_PRODUCT, SERVICE_UPDATE_DATA, SERVICE_FETCH_ORDER_HISTORY, SERVICE_ENRICH_ORDERS, \ - SERVICE_REFRESH_SLOTS + SERVICE_REFRESH_SLOTS, SERVICE_UPDATE_DELIVERY_TIMES _LOGGER = logging.getLogger(__name__) @@ -137,6 +137,16 @@ async def async_refresh_slots(call: ServiceCall) -> None: except Exception as err: raise HomeAssistantError(f"Failed to refresh slots: {err}") + async def async_update_delivery_times(call: ServiceCall) -> None: + """Cheaply refresh only the delivery-time announcement (ETA polling).""" + config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] + + account = _get_account(hass, config_entry_id) + try: + await account.refresh_delivery_times() + except Exception as err: + raise HomeAssistantError(f"Failed to update delivery times: {err}") + async def async_fetch_order_history(call: ServiceCall) -> None: """Fetch complete order history from Rohlik.""" config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] @@ -241,6 +251,16 @@ async def async_enrich_orders(call: ServiceCall) -> None: supports_response=SupportsResponse.NONE ) + hass.services.async_register( + DOMAIN, + SERVICE_UPDATE_DELIVERY_TIMES, + async_update_delivery_times, + schema=vol.Schema({ + vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string + }), + supports_response=SupportsResponse.NONE + ) + hass.services.async_register( DOMAIN, SERVICE_FETCH_ORDER_HISTORY, diff --git a/custom_components/rohlikcz/services.yaml b/custom_components/rohlikcz/services.yaml index 8cd77ab..66c4fa8 100644 --- a/custom_components/rohlikcz/services.yaml +++ b/custom_components/rohlikcz/services.yaml @@ -120,6 +120,18 @@ refresh_slots: config_entry: integration: rohlikcz +update_delivery_times: + name: Update delivery times + description: Cheaply refresh only the delivery-time announcement (one request). The delivery ETA of an upcoming order shifts often, so this is light enough to poll every minute shortly before a delivery to keep the delivery time sensor accurate. + fields: + config_entry_id: + name: Account + description: The Rohlik account to use + required: true + selector: + config_entry: + integration: rohlikcz + search_and_add_to_cart: name: Search and add to cart description: Search for a product and add to a shopping cart diff --git a/readme.md b/readme.md index 833fd0b..edfdd3f 100644 --- a/readme.md +++ b/readme.md @@ -152,19 +152,23 @@ Each sensor's attributes contain the top N items (configurable, default 10) sort | **`rohlikcz.get_cart_content`** | Get the current contents of your shopping cart | | **`rohlikcz.update_data`** | Force an immediate full data refresh from Rohlík.cz | | **`rohlikcz.refresh_slots`** | Cheaply refresh only the delivery-slot data with a single request — light enough to poll every few seconds to catch express availability | +| **`rohlikcz.update_delivery_times`** | Cheaply refresh only the delivery-time announcement with a single request — light enough to poll every minute to track the shifting delivery ETA | | **`rohlikcz.fetch_order_history`** | Download your complete order history and store it locally (backfill) | | **`rohlikcz.enrich_orders`** | Enrich stored orders with item details and product categories to populate the spending sensors | > [!TIP] > Want a notification the moment express delivery opens up? See [`automations/refresh_slots.yaml`](automations/refresh_slots.yaml) for an example that polls `refresh_slots` every 15 seconds while armed and notifies you when the **Express Available** sensor turns on. +> [!TIP] +> Delivery ETA shifting around before the courier arrives? See [`automations/update_delivery_times.yaml`](automations/update_delivery_times.yaml) for an example that polls `update_delivery_times` every minute during the last 30 minutes before delivery so the **Delivery Time** sensor stays accurate. + --- ## 🔄 Data Updates Data is refreshed from Rohlík.cz **every 10 minutes** automatically. The update covers account details, premium status, delivery slots, shopping cart, and order history. -You can trigger an immediate refresh at any time using the **`rohlikcz.update_data`** action, or refresh just the delivery slots more frequently with **`rohlikcz.refresh_slots`**. +You can trigger an immediate refresh at any time using the **`rohlikcz.update_data`** action, refresh just the delivery slots more frequently with **`rohlikcz.refresh_slots`**, or refresh just the delivery-time announcement with **`rohlikcz.update_delivery_times`**. --- diff --git a/tests/test_init.py b/tests/test_init.py index c0a0a7c..8cb303c 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -3,8 +3,9 @@ import json import os -from datetime import timedelta +from datetime import datetime, timedelta from unittest.mock import AsyncMock, patch +from zoneinfo import ZoneInfo from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_EMAIL, CONF_PASSWORD @@ -209,6 +210,51 @@ async def test_refresh_slots_updates_express_sensor(hass: HomeAssistant) -> None assert account.data["login"]["data"]["user"]["id"] == 123456 +async def test_update_delivery_times_updates_delivery_time_sensor(hass: HomeAssistant) -> None: + """refresh_delivery_times merges fresh announcements and the delivery time sensor reflects it.""" + data = sample_api_data() + + entry = _entry() + entry.add_to_hass(hass) + with _patch_get_data(return_value=data): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + ent_reg = er.async_get(hass) + delivery_time_id = ent_reg.async_get_entity_id("sensor", DOMAIN, "123456_delivery_time") + assert hass.states.get(delivery_time_id).state == "unknown" + + account = entry.runtime_data + fresh_announcements = { + "data": { + "announcements": [ + { + "id": 9002, + "title": "Delivery", + "updatedAt": "2026-04-26T07:30:00+02:00", + "content": ( + 'Doručíme 26.4.' + ' v 08:00' + ), + } + ] + } + } + account._client.delivery.get_announcements = AsyncMock(return_value=fresh_announcements) + + await account.refresh_delivery_times() + await hass.async_block_till_done() + + # Only the announcement data changed; the delivery time sensor picks up the ETA. + assert account.data["delivery_announcements"] == fresh_announcements + state = hass.states.get(delivery_time_id).state + assert dt_util.parse_datetime(state) == datetime( + datetime.now().year, 4, 26, 8, 0, tzinfo=ZoneInfo("Europe/Prague") + ) + # Other data is untouched. + assert account.data["login"]["data"]["user"]["id"] == 123456 + + async def test_slot_sensors_registered(hass: HomeAssistant) -> None: """The three preselected-slot sensors register and parse their data.""" data = sample_api_data()