diff --git a/custom_components/rohlikcz/__init__.py b/custom_components/rohlikcz/__init__.py index 280401a..917a683 100644 --- a/custom_components/rohlikcz/__init__.py +++ b/custom_components/rohlikcz/__init__.py @@ -6,11 +6,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from .const import ( DOMAIN, CONF_ANALYTICS, DEFAULT_ANALYTICS, CONF_TOP_N, DEFAULT_TOP_N, CONF_HIDE_DISCONTINUED, DEFAULT_HIDE_DISCONTINUED, - SERVICE_ADD_TO_CART, + SERVICE_ADD_TO_CART, CONF_SITE, get_site, ) from .hub import RohlikAccount from .services import register_services @@ -41,6 +42,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: RohlikConfigEntry) -> bo analytics = entry.options.get(CONF_ANALYTICS, DEFAULT_ANALYTICS) top_n = int(entry.options.get(CONF_TOP_N, DEFAULT_TOP_N)) hide_discontinued = entry.options.get(CONF_HIDE_DISCONTINUED, DEFAULT_HIDE_DISCONTINUED) + site = entry.data.get(CONF_SITE) + + # Load the shop's timezone off the event loop (reading tzdata is file I/O); + # the coordinator then picks it up from the cache. + await dt_util.async_get_time_zone(get_site(site).timezone) rohlik_hub = RohlikAccount( hass, @@ -50,6 +56,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RohlikConfigEntry) -> bo top_n=top_n, hide_discontinued=hide_discontinued, entry=entry, + site=site, ) # Performs the first refresh; raises ConfigEntryNotReady on connection diff --git a/custom_components/rohlikcz/calendar.py b/custom_components/rohlikcz/calendar.py index e370fca..3441bbb 100644 --- a/custom_components/rohlikcz/calendar.py +++ b/custom_components/rohlikcz/calendar.py @@ -127,7 +127,7 @@ def _update_events(self) -> None: if order.get("items_count") is not None: description_parts.append(f"Items: {order['items_count']}") if order.get("price") is not None: - description_parts.append(f"Price: {order['price']} CZK") + description_parts.append(f"Price: {order['price']} {self._rohlik_account.currency}") description = "\n".join(description_parts) if description_parts else None event = CalendarEvent( @@ -196,7 +196,7 @@ def _update_events(self) -> None: description_parts.append(f"Items: {order['itemsCount']}") price_amount = order.get("priceComposition", {}).get("total", {}).get("amount") if price_amount is not None: - description_parts.append(f"Price: {price_amount} CZK") + description_parts.append(f"Price: {price_amount} {self._rohlik_account.currency}") description = "\n".join(description_parts) if description_parts else None event = CalendarEvent( diff --git a/custom_components/rohlikcz/config_flow.py b/custom_components/rohlikcz/config_flow.py index 65e6da2..59811d4 100644 --- a/custom_components/rohlikcz/config_flow.py +++ b/custom_components/rohlikcz/config_flow.py @@ -19,6 +19,7 @@ from .const import ( DOMAIN, CONF_ANALYTICS, ANALYTICS_OPTIONS, DEFAULT_ANALYTICS, CONF_TOP_N, DEFAULT_TOP_N, CONF_HIDE_DISCONTINUED, DEFAULT_HIDE_DISCONTINUED, + CONF_SITE, DEFAULT_SITE, SITES, get_site, ) from rohlik_api import InvalidCredentialsError, RohlikAPI, RohlikAPIError @@ -31,7 +32,11 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Returns the account title and unique user id on success. """ # A one-shot client that owns (and on close fully tears down) its session. - client = RohlikAPI(data[CONF_EMAIL], data[CONF_PASSWORD]) + client = RohlikAPI( + data[CONF_EMAIL], + data[CONF_PASSWORD], + base_url=get_site(data.get(CONF_SITE)).base_url, + ) try: reply = await client.login() user = reply["data"]["user"] @@ -40,6 +45,14 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, await client.close() +SITE_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=list(SITES), + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_SITE, + ) +) + ANALYTICS_SCHEMA = vol.Schema({ vol.Optional(CONF_ANALYTICS, default=DEFAULT_ANALYTICS): SelectSelector( SelectSelectorConfig( @@ -96,6 +109,7 @@ async def async_step_user( return self.async_show_form( step_id="user", data_schema=vol.Schema({ + vol.Required(CONF_SITE, default=DEFAULT_SITE): SITE_SELECTOR, vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str, }), @@ -137,7 +151,7 @@ async def async_step_reauth_confirm( if user_input is not None: data = { - CONF_EMAIL: reauth_entry.data[CONF_EMAIL], + **reauth_entry.data, CONF_PASSWORD: user_input[CONF_PASSWORD], } try: @@ -161,6 +175,56 @@ async def async_step_reauth_confirm( description_placeholders={"email": reauth_entry.data[CONF_EMAIL]}, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Move an existing entry to another shop (e.g. Knuspr.de). + + Entries created before site selection existed all point at Rohlík.cz; + this lets them switch without being removed and re-added. + """ + entry = self._get_reconfigure_entry() + errors: dict[str, str] = {} + + if user_input is not None: + data = { + **entry.data, + CONF_SITE: user_input[CONF_SITE], + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + try: + info = await validate_input(self.hass, data) + except InvalidCredentialsError: + errors["base"] = "invalid_auth" + except RohlikAPIError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unknown exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(info["user_id"]) + if entry.unique_id is None: + # Entries from before unique ids existed have nothing to + # compare against: adopt the id unless another entry has it. + self._abort_if_unique_id_configured() + return self.async_update_reload_and_abort( + entry, unique_id=info["user_id"], data=data + ) + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort(entry, data=data) + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({ + vol.Required( + CONF_SITE, default=entry.data.get(CONF_SITE, DEFAULT_SITE) + ): SITE_SELECTOR, + vol.Required(CONF_PASSWORD): str, + }), + errors=errors, + description_placeholders={"email": entry.data[CONF_EMAIL]}, + ) + @staticmethod @callback def async_get_options_flow(config_entry: config_entries.ConfigEntry): diff --git a/custom_components/rohlikcz/const.py b/custom_components/rohlikcz/const.py index 7c2d956..cc9d5e7 100644 --- a/custom_components/rohlikcz/const.py +++ b/custom_components/rohlikcz/const.py @@ -4,12 +4,44 @@ from __future__ import annotations from aiohttp import ClientTimeout +from dataclasses import dataclass from typing import Final HTTP_TIMEOUT: Final = ClientTimeout(total=10) DOMAIN = "rohlikcz" +""" Sites """ + + +@dataclass(frozen=True) +class Site: + """A Rohlík Group shop. All of them run the same backend API.""" + + base_url: str + name: str + currency: str + timezone: str + + +CONF_SITE = "site" +DEFAULT_SITE = "cz" +SITES: Final[dict[str, Site]] = { + "cz": Site("https://www.rohlik.cz", "Rohlík.cz", "CZK", "Europe/Prague"), + "de": Site("https://www.knuspr.de", "Knuspr.de", "EUR", "Europe/Berlin"), + "at": Site("https://www.gurkerl.at", "Gurkerl.at", "EUR", "Europe/Vienna"), + "hu": Site("https://www.kifli.hu", "Kifli.hu", "HUF", "Europe/Budapest"), + "ro": Site("https://www.sezamo.ro", "Sezamo.ro", "RON", "Europe/Bucharest"), +} + + +def get_site(key: str | None) -> Site: + """Return the site for a config entry's site key. + + Entries created before site selection existed have no key and are Rohlík.cz. + """ + return SITES.get(key or DEFAULT_SITE, SITES[DEFAULT_SITE]) + """Icons""" ICON_PARENTCLUB = "mdi:human-male-female-child" ICON_CREDIT = "mdi:cash-multiple" diff --git a/custom_components/rohlikcz/hub.py b/custom_components/rohlikcz/hub.py index 8c78dac..8ad60df 100644 --- a/custom_components/rohlikcz/hub.py +++ b/custom_components/rohlikcz/hub.py @@ -18,7 +18,7 @@ from homeassistant.util import dt as dt_util from rohlik_api import InvalidCredentialsError, RohlikAPI, RohlikAPIError -from .const import DOMAIN +from .const import DOMAIN, Site, get_site #: How often the integration refreshes data from the Rohlik API. UPDATE_INTERVAL = timedelta(seconds=600) @@ -174,7 +174,7 @@ def process_orders(self, orders: list) -> int: if new_count > 0: if not self._data["tracking_since"]: - self._data["tracking_since"] = datetime.now(ZoneInfo("Europe/Prague")).isoformat() + self._data["tracking_since"] = dt_util.now().isoformat() _LOGGER.info(f"Added {new_count} new orders to store. Total: {len(self._data['orders'])}") return new_count @@ -383,7 +383,7 @@ def item_totals(self, year: str | None = None, hide_discontinued: bool = False) class RohlikAccount(DataUpdateCoordinator[dict]): """RohlikCZ account modelled as a Home Assistant data update coordinator.""" - def __init__(self, hass: HomeAssistant, username: str, password: str, analytics: list[str] | None = None, top_n: int = 10, hide_discontinued: bool = True, entry: ConfigEntry | None = None) -> None: + def __init__(self, hass: HomeAssistant, username: str, password: str, analytics: list[str] | None = None, top_n: int = 10, hide_discontinued: bool = True, entry: ConfigEntry | None = None, site: str | None = None) -> None: """Initialize account info.""" super().__init__( hass, @@ -394,12 +394,18 @@ def __init__(self, hass: HomeAssistant, username: str, password: str, analytics: ) self._username: str = username self._password: str = password + self._site: Site = get_site(site) + # get_time_zone is cached; async_setup_entry preloads the zone off the + # event loop, so this does no file I/O there. + self._timezone: ZoneInfo = dt_util.get_time_zone(self._site.timezone) # A dedicated, HA-managed aiohttp session (own cookie jar) keeps each # account's auth cookies isolated from other integrations and from a # second Rohlik account. The client logs in lazily and re-authenticates # transparently on a 401, reusing this session across calls. self._session = async_create_clientsession(hass) - self._client = RohlikAPI(self._username, self._password, session=self._session) + self._client = RohlikAPI( + self._username, self._password, base_url=self._site.base_url, session=self._session + ) self._order_store: OrderStore | None = None self._last_refresh: datetime | None = None # When each delivery announcement (keyed by order ID and text) was first @@ -434,6 +440,16 @@ def hide_discontinued(self) -> bool: """Whether to exclude discontinued products from top N.""" return self._hide_discontinued + @property + def currency(self) -> str: + """ISO currency code of the account's shop, e.g. ``CZK``.""" + return self._site.currency + + @property + def timezone(self) -> ZoneInfo: + """Local timezone of the account's shop.""" + return self._timezone + @property def has_address(self) -> bool: return bool((self.data or {}).get("next_delivery_slot")) @@ -441,7 +457,7 @@ def has_address(self) -> bool: @property def device_info(self) -> DeviceInfo: """ Provides a device info. """ - return {"identifiers": {(DOMAIN, self.data["login"]["data"]["user"]["id"])}, "name": self.data["login"]["data"]["user"]["name"], "manufacturer": "Rohlík.cz"} + return {"identifiers": {(DOMAIN, self.data["login"]["data"]["user"]["id"])}, "name": self.data["login"]["data"]["user"]["name"], "manufacturer": self._site.name} @property def account_name(self) -> str: @@ -518,7 +534,7 @@ async def _async_update_data(self) -> dict: except RohlikAPIError as err: raise UpdateFailed(str(err)) from err - self._last_refresh = datetime.now(ZoneInfo("Europe/Prague")) + self._last_refresh = datetime.now(self._timezone) # Initialize order store on first update (only if analytics enabled) if self._analytics and not self._order_store and data.get("login"): diff --git a/custom_components/rohlikcz/sensor.py b/custom_components/rohlikcz/sensor.py index 336b8a7..2260b12 100644 --- a/custom_components/rohlikcz/sensor.py +++ b/custom_components/rohlikcz/sensor.py @@ -7,7 +7,6 @@ from collections.abc import Mapping from datetime import datetime, timedelta from typing import Any, Literal -from zoneinfo import ZoneInfo from dataclasses import dataclass from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass, SensorEntityDescription from homeassistant.config_entries import ConfigEntry @@ -110,6 +109,7 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: delivery_time = extract_delivery_datetime( delivery_info[0].get("content", ""), self._rohlik_account.announcement_received_at(delivery_info[0]), + tz=self._rohlik_account.timezone, ) if delivery_info[0].get("additionalContent", None): @@ -300,6 +300,7 @@ def native_value(self) -> datetime | None: delivery_time = extract_delivery_datetime( announcement.get("content", ""), self._rohlik_account.announcement_received_at(announcement), + tz=self._rohlik_account.timezone, ) if delivery_time is not None: self._last_value = delivery_time @@ -528,23 +529,37 @@ def icon(self) -> str: return ICON_PHONE -class CreditAmount(BaseEntity, SensorEntity): +class MoneySensor(BaseEntity, SensorEntity): + """Sensor whose value is an amount in the account's currency.""" + + def __init__(self, rohlik_account: RohlikAccount) -> None: + super().__init__(rohlik_account) + currency = rohlik_account.currency + # The unit used to come from the translations, so Czech-language + # installs showed "Kč". Keep it, or their long-term statistics would + # report a unit change. + if currency == "CZK" and rohlik_account.hass.config.language == "cs": + currency = "Kč" + self._attr_native_unit_of_measurement = currency + + +class CreditAmount(MoneySensor): """Sensor for credit amount.""" _attr_translation_key = "credit_amount" _attr_should_poll = False @property - def native_value(self) -> float | str: + def native_value(self) -> float | None: """Returns amount of credit as state.""" - return self._rohlik_account.data.get('login', {}).get('data', {}).get('user', {}).get('credits', "N/A") + return self._rohlik_account.data.get('login', {}).get('data', {}).get('user', {}).get('credits') @property def icon(self) -> str: return ICON_CREDIT -class MonthlySpent(BaseEntity, SensorEntity, RestoreEntity): +class MonthlySpent(MoneySensor, RestoreEntity): """Sensor for amount spent in current month with HA-side accumulation. Only tracks orders that are delivered and closed (have final price). @@ -560,7 +575,7 @@ def __init__(self, rohlik_account: RohlikAccount) -> None: super().__init__(rohlik_account) self._monthly_total: float = 0.0 self._processed_orders: set[str] = set() # Store order IDs - self._current_month: str = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y-%m") + self._current_month: str = datetime.now(self._rohlik_account.timezone).strftime("%Y-%m") self._last_reset: datetime | None = None def _is_order_final(self, order: dict) -> bool: @@ -599,7 +614,7 @@ async def async_added_to_hass(self) -> None: if (last_state := await self.async_get_last_state()) is not None: self._monthly_total = last_state.attributes.get("monthly_total", 0.0) self._processed_orders = set(last_state.attributes.get("processed_orders", [])) - self._current_month = last_state.attributes.get("current_month", datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y-%m")) + self._current_month = last_state.attributes.get("current_month", datetime.now(self._rohlik_account.timezone).strftime("%Y-%m")) if last_reset_str := last_state.attributes.get("last_reset"): self._last_reset = datetime.fromisoformat(last_reset_str) @@ -609,13 +624,13 @@ async def async_added_to_hass(self) -> None: def _check_and_reset_month(self) -> None: """Reset total if month changed.""" - current_month = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y-%m") + current_month = datetime.now(self._rohlik_account.timezone).strftime("%Y-%m") if current_month != self._current_month: _LOGGER.info(f"Month changed from {self._current_month} to {current_month}, resetting monthly total") self._monthly_total = 0.0 self._processed_orders = set() self._current_month = current_month - self._last_reset = datetime.now(ZoneInfo("Europe/Prague")) + self._last_reset = datetime.now(self._rohlik_account.timezone) def _process_new_orders(self) -> None: """Process new orders and add to total. @@ -627,7 +642,7 @@ def _process_new_orders(self) -> None: if not orders: return - current_month_pattern = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y-%m-") + current_month_pattern = datetime.now(self._rohlik_account.timezone).strftime("%Y-%m-") new_orders_count = 0 for order in orders: @@ -663,14 +678,14 @@ def _process_new_orders(self) -> None: self._processed_orders.add(order_key) new_orders_count += 1 - _LOGGER.debug(f"Added order {order_id} with amount {amount} CZK. New total: {self._monthly_total} CZK") + _LOGGER.debug(f"Added order {order_id} with amount {amount}. New total: {self._monthly_total}") except (KeyError, ValueError, TypeError) as e: _LOGGER.warning(f"Skipping order due to error: {e}, order ID: {order.get('id')}") continue if new_orders_count > 0: - _LOGGER.info(f"Processed {new_orders_count} new order(s). Monthly total: {self._monthly_total} CZK") + _LOGGER.info(f"Processed {new_orders_count} new order(s). Monthly total: {self._monthly_total}") @property def native_value(self) -> float | None: @@ -701,7 +716,7 @@ def icon(self) -> str: return ICON_MONTHLY_SPENT -class YearlySpent(BaseEntity, SensorEntity): +class YearlySpent(MoneySensor): """Sensor for amount spent in current year from persistent order store.""" _attr_translation_key = "yearly_spent" @@ -714,7 +729,7 @@ def native_value(self) -> float | None: store = self._rohlik_account.order_store if not store: return None - year = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y") + year = datetime.now(self._rohlik_account.timezone).strftime("%Y") return store.yearly_total(year) @property @@ -722,7 +737,7 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: store = self._rohlik_account.order_store if not store: return None - year = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y") + year = datetime.now(self._rohlik_account.timezone).strftime("%Y") count = store.yearly_count(year) total = store.yearly_total(year) return { @@ -736,7 +751,7 @@ def icon(self) -> str: return ICON_YEARLY_SPENT -class AllTimeSpent(BaseEntity, SensorEntity): +class AllTimeSpent(MoneySensor): """Sensor for total amount spent across all tracked orders.""" _attr_translation_key = "alltime_spent" @@ -820,9 +835,8 @@ def __init__(self, rohlik_account: RohlikAccount, description: SpendingBreakdown self.entity_description = description super().__init__(rohlik_account) - @staticmethod - def _year() -> str: - return datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y") + def _year(self) -> str: + return datetime.now(self._rohlik_account.timezone).strftime("%Y") def _entries(self, store: OrderStore, year: str) -> list: d = self.entity_description @@ -939,7 +953,7 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: extra_attr: dict = {"Max Bags": bags_data.get('max', 0)} if bags_data.get('deposit', None): extra_attr["Deposit Amount"] = bags_data.get('deposit').get('amount', 0) - extra_attr["Deposit Currency"] = bags_data.get('deposit').get('currency', 'CZK') + extra_attr["Deposit Currency"] = bags_data.get('deposit').get('currency', self._rohlik_account.currency) return extra_attr @property @@ -976,7 +990,7 @@ def icon(self) -> str: return ICON_PREMIUM_DAYS -class CartPriceSensor(BaseEntity, SensorEntity): +class CartPriceSensor(MoneySensor): """Sensor for total cart price.""" _attr_translation_key = "cart_price" diff --git a/custom_components/rohlikcz/todo.py b/custom_components/rohlikcz/todo.py index a256dd3..8874cb9 100644 --- a/custom_components/rohlikcz/todo.py +++ b/custom_components/rohlikcz/todo.py @@ -63,7 +63,7 @@ def todo_items(self) -> list[TodoItem] | None: items = [] for product in cart.products: # Format the summary to include relevant information - summary = f"{product.name} ({product.quantity}) - {product.price} Kč" + summary = f"{product.name} ({product.quantity}) - {product.price} {self._rohlik_hub.currency}" # Use cart_item_id as the unique identifier for cart items items.append( diff --git a/custom_components/rohlikcz/translations/cs.json b/custom_components/rohlikcz/translations/cs.json index 761b724..fda90dd 100644 --- a/custom_components/rohlikcz/translations/cs.json +++ b/custom_components/rohlikcz/translations/cs.json @@ -2,15 +2,17 @@ "config": { "step": { "user": { - "title": "Účet Rohlik.cz", - "description": "Zadejte své přihlašovací údaje do Rohlik.cz.", + "title": "Účet Rohlík", + "description": "Vyberte obchod a zadejte do něj své přihlašovací údaje.", "data": { + "site": "Obchod", "email": "E-mail", "password": "Heslo" }, "data_description": { - "email": "Váš e-mail pro přihlášení do Rohlik.cz", - "password": "Vaše heslo pro přihlášení do Rohlik.cz" + "site": "Obchod skupiny Rohlík, u kterého máte účet.", + "email": "Váš e-mail pro přihlášení do obchodu", + "password": "Vaše heslo pro přihlášení do obchodu" } }, "analytics": { @@ -27,22 +29,31 @@ } }, "reauth_confirm": { - "title": "Opětovné přihlášení Rohlik.cz", + "title": "Opětovné přihlášení k účtu Rohlík", "description": "Uložené přihlašovací údaje pro {email} již nejsou platné. Zadejte znovu heslo.", "data": { "password": "Heslo" } + }, + "reconfigure": { + "title": "Změna obchodu", + "description": "Vyberte obchod, u kterého je účet {email}, a zadejte jeho heslo.", + "data": { + "site": "Obchod", + "password": "Heslo" + } } }, "error": { - "cannot_connect": "Připojení k Rohlik.cz selhalo", + "cannot_connect": "Připojení k obchodu selhalo", "invalid_auth": "Neplatné přihlašovací údaje", "unknown": "Neočekávaná chyba" }, "abort": { "already_configured": "Účet je již nakonfigurován", "reauth_successful": "Opětovné přihlášení proběhlo úspěšně", - "wrong_account": "Přihlašovací údaje patří jinému účtu Rohlik" + "wrong_account": "Přihlašovací údaje patří jinému účtu Rohlik", + "reconfigure_successful": "Účet byl přesunut do vybraného obchodu" } }, "options": { @@ -63,6 +74,15 @@ } }, "selector": { + "site": { + "options": { + "cz": "Rohlík.cz (Česko)", + "de": "Knuspr.de (Německo)", + "at": "Gurkerl.at (Rakousko)", + "hu": "Kifli.hu (Maďarsko)", + "ro": "Sezamo.ro (Rumunsko)" + } + }, "analytics": { "options": { "categories_l0": "Nejvyšší kategorie (např. Nápoje, Drogerie, Ovoce a zelenina)", @@ -138,8 +158,7 @@ "name": "Expresní doručení zdarma" }, "credit_amount": { - "name": "Zůstatek kreditu", - "unit_of_measurement": "Kč" + "name": "Zůstatek kreditu" }, "bags_amount": { "name": "Počet tašek", @@ -149,8 +168,7 @@ "name": "Zbývající dny Xtra" }, "cart_price": { - "name": "Cena košíku", - "unit_of_measurement": "Kč" + "name": "Cena košíku" }, "updated": { "name": "Aktualizováno" @@ -180,16 +198,13 @@ "name": "Čas doručení" }, "monthly_spent": { - "name": "Měsíční útrata", - "unit_of_measurement": "Kč" + "name": "Měsíční útrata" }, "yearly_spent": { - "name": "Roční útrata", - "unit_of_measurement": "Kč" + "name": "Roční útrata" }, "alltime_spent": { - "name": "Celková útrata", - "unit_of_measurement": "Kč" + "name": "Celková útrata" }, "categories_l0_this_year": { "name": "Hlavní kategorie tento rok" diff --git a/custom_components/rohlikcz/translations/en.json b/custom_components/rohlikcz/translations/en.json index 110f8ad..b3807fc 100644 --- a/custom_components/rohlikcz/translations/en.json +++ b/custom_components/rohlikcz/translations/en.json @@ -2,15 +2,17 @@ "config": { "step": { "user": { - "title": "Rohlik.cz Account", - "description": "Enter your Rohlik.cz login credentials.", + "title": "Rohlík account", + "description": "Choose your shop and enter its login credentials.", "data": { + "site": "Shop", "email": "Email", "password": "Password" }, "data_description": { - "email": "Your Rohlik.cz account email", - "password": "Your Rohlik.cz account password" + "site": "The Rohlík Group shop your account is with.", + "email": "Your shop account email", + "password": "Your shop account password" } }, "analytics": { @@ -27,22 +29,31 @@ } }, "reauth_confirm": { - "title": "Re-authenticate Rohlik.cz", + "title": "Re-authenticate Rohlík account", "description": "The stored credentials for {email} are no longer valid. Enter the password again.", "data": { "password": "Password" } + }, + "reconfigure": { + "title": "Change shop", + "description": "Choose the shop that the account {email} is with, and enter its password.", + "data": { + "site": "Shop", + "password": "Password" + } } }, "error": { - "cannot_connect": "Failed to connect to Rohlik.cz", + "cannot_connect": "Failed to connect to the shop", "invalid_auth": "Invalid email or password", "unknown": "Unexpected error" }, "abort": { "already_configured": "Account is already configured", "reauth_successful": "Re-authentication was successful", - "wrong_account": "The credentials are for a different Rohlik account" + "wrong_account": "The credentials are for a different Rohlik account", + "reconfigure_successful": "The account was moved to the selected shop" } }, "options": { @@ -63,6 +74,15 @@ } }, "selector": { + "site": { + "options": { + "cz": "Rohlík.cz (Czechia)", + "de": "Knuspr.de (Germany)", + "at": "Gurkerl.at (Austria)", + "hu": "Kifli.hu (Hungary)", + "ro": "Sezamo.ro (Romania)" + } + }, "analytics": { "options": { "categories_l0": "Top-level categories (e.g. Drinks, Drugstore, Fruit & Vegetables)", @@ -138,8 +158,7 @@ "name": "Free Express Deliveries" }, "credit_amount": { - "name": "Credit Balance", - "unit_of_measurement": "CZK" + "name": "Credit Balance" }, "bags_amount": { "name": "Bags Amount", @@ -149,8 +168,7 @@ "name": "Xtra Days Remaining" }, "cart_price": { - "name": "Cart Total", - "unit_of_measurement": "CZK" + "name": "Cart Total" }, "updated": { "name": "Last Updated" @@ -180,16 +198,13 @@ "name": "Delivery Time" }, "monthly_spent": { - "name": "Spent This Month", - "unit_of_measurement": "CZK" + "name": "Spent This Month" }, "yearly_spent": { - "name": "Spent This Year", - "unit_of_measurement": "CZK" + "name": "Spent This Year" }, "alltime_spent": { - "name": "Spent All Time", - "unit_of_measurement": "CZK" + "name": "Spent All Time" }, "categories_l0_this_year": { "name": "Top Categories This Year" diff --git a/custom_components/rohlikcz/utils.py b/custom_components/rohlikcz/utils.py index a444900..8aee6da 100644 --- a/custom_components/rohlikcz/utils.py +++ b/custom_components/rohlikcz/utils.py @@ -79,7 +79,9 @@ def _resolve_clock_time( return delivery_dt -def extract_delivery_datetime(text: str, received_at: datetime | None = None) -> datetime | None: +def extract_delivery_datetime( + text: str, received_at: datetime | None = None, tz: ZoneInfo | None = None +) -> datetime | None: """ Extract delivery time information from various formatted strings and return a datetime object. @@ -95,6 +97,8 @@ def extract_delivery_datetime(text: str, received_at: datetime | None = None) -> minutes") messages are counted from it, but never resolve to a time already past (an overdue courier is due now), and clock times are placed on its day. Defaults to the current time. + tz: The shop's local timezone, which clock times in the text are in. + Defaults to Europe/Prague (Rohlík.cz). Returns: A timezone-aware datetime object representing the delivery time, or None if no valid time found @@ -110,9 +114,9 @@ def extract_delivery_datetime(text: str, received_at: datetime | None = None) -> # Get plain text without HTML tags or entities ( ) for pattern detection plain_text: str = html.unescape(re.sub(r'<[^>]+>', '', clean_text)) - prague_tz = ZoneInfo('Europe/Prague') - now = datetime.now(tz=prague_tz) - received_at = received_at.astimezone(prague_tz) if received_at is not None else now + local_tz = tz or ZoneInfo('Europe/Prague') + now = datetime.now(tz=local_tz) + received_at = received_at.astimezone(local_tz) if received_at is not None else now # Type 1: Date and time date_matches = _HIGHLIGHTED_DATE_PATTERN.findall(clean_text) @@ -122,7 +126,7 @@ def extract_delivery_datetime(text: str, received_at: datetime | None = None) -> try: day, month = map(int, date_matches[0].replace('.', ' ').split()) # e.g. "26.4." hour, minute = map(int, time_matches[0].split(':')) # e.g. "08:00" - delivery_dt = datetime(received_at.year, month, day, hour, minute, tzinfo=prague_tz) + delivery_dt = datetime(received_at.year, month, day, hour, minute, tzinfo=local_tz) if delivery_dt < received_at - timedelta(days=180): # Announced in December for early January. delivery_dt = delivery_dt.replace(year=received_at.year + 1) diff --git a/readme.md b/readme.md index 20e1e7f..a0776d6 100644 --- a/readme.md +++ b/readme.md @@ -7,7 +7,7 @@ Bring your **[Rohlík.cz](https://www.rohlik.cz)** grocery deliveries into Home Assistant! Track deliveries, monitor your cart, automate shopping, and never miss a delivery window — all from your smart home dashboard. > **What is Rohlík.cz?** -> Rohlík.cz is one of the most popular online grocery and food-delivery services in the Czech Republic (also operating as Knuspr in Germany and Austria, and Kifli.hu in Hungary). They deliver fresh groceries, household goods and more — often within hours. +> Rohlík.cz is one of the most popular online grocery and food-delivery services in the Czech Republic (also operating as Knuspr.de in Germany, Gurkerl.at in Austria, Kifli.hu in Hungary and Sezamo.ro in Romania — all supported by this integration). They deliver fresh groceries, household goods and more — often within hours. > [!WARNING] > This integration uses a reverse-engineered API from the Rohlík.cz website. It is **not** officially supported by Rohlík.cz. Use it at your own risk. @@ -62,13 +62,22 @@ HACS hides pre-releases by default, so the 1.0.0 beta will not show up until you 1. Go to **Settings → Devices & Services** in your Home Assistant UI. 2. Click **Add Integration** (the `+` button in the bottom right). 3. Search for **Rohlik.cz** and select it. -4. Enter your Rohlík.cz credentials: - - **Email** — your Rohlík.cz account email - - **Password** — your Rohlík.cz account password +4. Pick your **Shop** and enter its credentials: + - **Shop** — Rohlík.cz, Knuspr.de, Gurkerl.at, Kifli.hu or Sezamo.ro + - **Email** — your account email + - **Password** — your account password 5. Click **Submit** — entities will be set up automatically. +Amounts (credit, cart total, spending) are reported in the shop's currency (CZK, EUR, HUF or RON). + +> [!NOTE] +> If your password later changes or stops working, Home Assistant prompts you to re-enter it (**re-authentication**) instead of the integration silently failing. Each account can only be added once. + +> [!TIP] +> Entries created before shop selection existed point at Rohlík.cz. To move one to another shop (e.g. Knuspr.de), open the integration entry's **⋮ menu → Reconfigure** and pick the shop — no need to remove and re-add it. + > [!NOTE] -> If your password later changes or stops working, Home Assistant prompts you to re-enter it (**re-authentication**) instead of the integration silently failing. Each Rohlík.cz account can only be added once. +> The **Delivery Time** sensor reads the courier's arrival time from the shop's delivery announcement. Announcements that give a clock time work in every shop; "in N minutes" countdowns are currently only understood in Czech. ### Optional: Spending Analytics @@ -105,7 +114,7 @@ Enabling analytics triggers a one-time download of your full order history (this | **Phone** | Your registered phone number | | **Remaining Orders Without Limit** | Premium orders with no minimum price limit remaining | | **Remaining Free Express Deliveries** | Free express deliveries still available | -| **Credit Balance** | Your current account credit (CZK) | +| **Credit Balance** | Your current account credit (in the shop's currency) | | **Reusable Bags** | Number of reusable bags on your account | | **Premium Days Remaining** | Days left in your premium subscription *(premium users only)* | | **Cart Total** | Current total value of your shopping cart | @@ -116,7 +125,7 @@ Enabling analytics triggers a one-time download of your full order history (this | **Delivery Slot Start** | Start of the delivery window for your next order | | **Delivery Slot End** | End of the delivery window for your next order | | **Delivery Time** | Predicted exact delivery time for your next order | -| **Monthly Spent** | Total amount spent on Rohlík.cz this month | +| **Monthly Spent** | Total amount spent this month | | **Yearly Spent** | Total amount spent this year *(requires Spending Analytics)* | | **All Time Spent** | Total spent across all tracked orders *(requires Spending Analytics)*; the `by_year` attribute breaks the total down per year (`total` and `order_count` per year) | diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 3580435..d6d4679 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1,7 +1,7 @@ """Tests for the Rohlik.cz config and reauth flows.""" from __future__ import annotations -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_EMAIL, CONF_PASSWORD @@ -11,7 +11,8 @@ from rohlik_api import InvalidCredentialsError, RohlikAPIError -from custom_components.rohlikcz.const import DOMAIN +from custom_components.rohlikcz.config_flow import validate_input +from custom_components.rohlikcz.const import CONF_SITE, DOMAIN VALID = {"title": "Test User", "user_id": "123456"} USER_INPUT = {CONF_EMAIL: "test@example.com", CONF_PASSWORD: "secret"} @@ -40,7 +41,45 @@ async def test_user_flow_success(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "Test User" assert result["result"].unique_id == "123456" - assert result["data"] == USER_INPUT + # The shop defaults to Rohlík.cz when not picked. + assert result["data"] == {**USER_INPUT, CONF_SITE: "cz"} + + +async def test_user_flow_other_site(hass: HomeAssistant) -> None: + """Picking another shop validates against it and stores it on the entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "custom_components.rohlikcz.config_flow.validate_input", return_value=VALID + ) as mock_validate, patch( + "custom_components.rohlikcz.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {**USER_INPUT, CONF_SITE: "de"} + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {**USER_INPUT, CONF_SITE: "de"} + assert mock_validate.call_args.args[1][CONF_SITE] == "de" + + +async def test_validate_input_uses_site_base_url(hass: HomeAssistant) -> None: + """The login check goes to the selected shop, and to Rohlík.cz by default.""" + with patch("custom_components.rohlikcz.config_flow.RohlikAPI") as mock_api: + client = mock_api.return_value + client.login = AsyncMock( + return_value={"data": {"user": {"name": "Test User", "id": 123456}}} + ) + client.close = AsyncMock() + + assert await validate_input(hass, {**USER_INPUT, CONF_SITE: "hu"}) == VALID + assert mock_api.call_args.kwargs["base_url"] == "https://www.kifli.hu" + + await validate_input(hass, USER_INPUT) + assert mock_api.call_args.kwargs["base_url"] == "https://www.rohlik.cz" async def test_user_flow_invalid_auth(hass: HomeAssistant) -> None: @@ -133,6 +172,132 @@ async def test_reauth_success(hass: HomeAssistant) -> None: assert entry.data[CONF_PASSWORD] == "new-password" +async def test_reauth_keeps_site(hass: HomeAssistant) -> None: + """Reauth logs in to the entry's own shop and keeps it on the entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="123456", + data={CONF_EMAIL: "test@example.com", CONF_PASSWORD: "old", CONF_SITE: "at"}, + ) + entry.add_to_hass(hass) + + result = await entry.start_reauth_flow(hass) + with patch( + "custom_components.rohlikcz.config_flow.validate_input", return_value=VALID + ) as mock_validate, patch( + "custom_components.rohlikcz.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "new-password"} + ) + + assert result["reason"] == "reauth_successful" + assert mock_validate.call_args.args[1][CONF_SITE] == "at" + assert entry.data[CONF_SITE] == "at" + + +async def test_reconfigure_moves_entry_to_other_site(hass: HomeAssistant) -> None: + """An entry from before site selection can be switched to Knuspr.de.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="123456", + data={CONF_EMAIL: "test@example.com", CONF_PASSWORD: "old"}, + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + with patch( + "custom_components.rohlikcz.config_flow.validate_input", return_value=VALID + ) as mock_validate, patch( + "custom_components.rohlikcz.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_SITE: "de", CONF_PASSWORD: "new-password"} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_validate.call_args.args[1][CONF_SITE] == "de" + assert entry.data == { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "new-password", + CONF_SITE: "de", + } + + +async def test_reconfigure_invalid_auth(hass: HomeAssistant) -> None: + """Credentials the chosen shop rejects keep the form open.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="123456", + data={CONF_EMAIL: "test@example.com", CONF_PASSWORD: "old"}, + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + with patch( + "custom_components.rohlikcz.config_flow.validate_input", + side_effect=InvalidCredentialsError("bad"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_SITE: "de", CONF_PASSWORD: "wrong"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + assert CONF_SITE not in entry.data + + +async def test_reconfigure_wrong_account(hass: HomeAssistant) -> None: + """Reconfiguring onto a different account aborts with wrong_account.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="123456", + data={CONF_EMAIL: "test@example.com", CONF_PASSWORD: "old"}, + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + with patch( + "custom_components.rohlikcz.config_flow.validate_input", + return_value={"title": "Other", "user_id": "999999"}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_SITE: "de", CONF_PASSWORD: "new-password"} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "wrong_account" + + +async def test_reconfigure_legacy_entry_without_unique_id(hass: HomeAssistant) -> None: + """An entry created before unique ids existed can still be reconfigured.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=None, + data={CONF_EMAIL: "test@example.com", CONF_PASSWORD: "old"}, + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + with patch( + "custom_components.rohlikcz.config_flow.validate_input", return_value=VALID + ), patch( + "custom_components.rohlikcz.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_SITE: "de", CONF_PASSWORD: "new-password"} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.unique_id == VALID["user_id"] + assert entry.data[CONF_SITE] == "de" + + async def test_reauth_wrong_account(hass: HomeAssistant) -> None: """Reauth with a different account id aborts with wrong_account.""" entry = MockConfigEntry( diff --git a/tests/test_extract_delivery_datetime.py b/tests/test_extract_delivery_datetime.py index 136f29a..69e41cf 100644 --- a/tests/test_extract_delivery_datetime.py +++ b/tests/test_extract_delivery_datetime.py @@ -88,6 +88,16 @@ def test_date_and_time() -> None: ) +def test_clock_time_in_shop_timezone() -> None: + """Clock times are read in the shop's own timezone (Sezamo.ro: Bucharest).""" + bucharest = ZoneInfo("Europe/Bucharest") + content = f"Livrăm la {HIGHLIGHT.format('12:30')}" + + assert extract_delivery_datetime(content, RECEIVED_AT, tz=bucharest) == datetime( + 2026, 9, 20, 12, 30, tzinfo=bucharest + ) + + def test_plain_clock_time_is_last_resort() -> None: """An unhighlighted time is used when nothing else matches.""" assert extract_delivery_datetime("Doručíme v 10:11.", RECEIVED_AT) == datetime( diff --git a/tests/test_init.py b/tests/test_init.py index a0c0515..c5aaa7f 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -16,7 +16,7 @@ from rohlik_api import APIRequestFailedError, InvalidCredentialsError -from custom_components.rohlikcz.const import CONF_ANALYTICS, DOMAIN +from custom_components.rohlikcz.const import CONF_ANALYTICS, CONF_SITE, DOMAIN from custom_components.rohlikcz.hub import OrderStore, RohlikAccount from fixtures_data import sample_api_data @@ -67,6 +67,61 @@ async def test_setup_creates_entities_and_unloads(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.NOT_LOADED +async def test_setup_uses_entry_site(hass: HomeAssistant) -> None: + """A non-Czech entry talks to its own shop and reports its currency.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="123456", + data={**ENTRY_DATA, CONF_SITE: "de"}, + options={}, + ) + entry.add_to_hass(hass) + + with _patch_get_data(return_value=sample_api_data()): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + account = entry.runtime_data + assert account._client.base_url == "https://www.knuspr.de" + assert account.timezone == ZoneInfo("Europe/Berlin") + assert account.device_info["manufacturer"] == "Knuspr.de" + + ent_reg = er.async_get(hass) + for key in ("cart_price", "credit_amount", "monthly_spent"): + entity_id = ent_reg.async_get_entity_id("sensor", DOMAIN, f"123456_{key}") + assert hass.states.get(entity_id).attributes["unit_of_measurement"] == "EUR" + + +async def test_setup_without_site_is_rohlik_cz(hass: HomeAssistant) -> None: + """Entries from before site selection keep using Rohlík.cz and CZK.""" + entry = _entry() + entry.add_to_hass(hass) + + with _patch_get_data(return_value=sample_api_data()): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.runtime_data._client.base_url == "https://www.rohlik.cz" + ent_reg = er.async_get(hass) + entity_id = ent_reg.async_get_entity_id("sensor", DOMAIN, "123456_cart_price") + assert hass.states.get(entity_id).attributes["unit_of_measurement"] == "CZK" + + +async def test_czech_language_keeps_kc_unit(hass: HomeAssistant) -> None: + """Czech installs keep the "Kč" unit their statistics were recorded in.""" + hass.config.language = "cs" + entry = _entry() + entry.add_to_hass(hass) + + with _patch_get_data(return_value=sample_api_data()): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + ent_reg = er.async_get(hass) + entity_id = ent_reg.async_get_entity_id("sensor", DOMAIN, "123456_monthly_spent") + assert hass.states.get(entity_id).attributes["unit_of_measurement"] == "Kč" + + async def test_unload_leaves_ha_session_to_home_assistant( hass: HomeAssistant, caplog ) -> None: