Skip to content

Commit 9ae8098

Browse files
authored
Merge pull request #48 from dvejsada/bug-logout
Bug logout
2 parents 8649fb9 + 1dbff6c commit 9ae8098

10 files changed

Lines changed: 325 additions & 140 deletions

File tree

custom_components/rohlikcz/const.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
ICON_CALENDAR_CHECK = "mdi:calendar-check"
3434
ICON_CALENDAR_REMOVE = "mdi:calendar-remove"
3535
ICON_INFO = "mdi:information-outline"
36+
ICON_DELIVERY_TIME = "mdi:timer-sand"
37+
ICON_MONTHLY_SPENT = "mdi:cash-register"
3638

3739
""" Service attributes """
3840
ATTR_CONFIG_ENTRY_ID = "config_entry_id"
@@ -49,3 +51,4 @@
4951
SERVICE_GET_SHOPPING_LIST = "get_shopping_list"
5052
SERVICE_GET_CART_CONTENT = "get_cart_content"
5153
SERVICE_SEARCH_AND_ADD_PRODUCT = "search_and_add_to_cart"
54+
SERVICE_UPDATE_DATA = "update_data"

custom_components/rohlikcz/hub.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ def unique_id(self) -> str:
4343
"""Return the unique ID for this account."""
4444
return self.data["login"]["data"]["user"]["id"]
4545

46+
@property
47+
def is_ordered(self) -> bool:
48+
return len(self.data.get('next_order', [])) > 0
49+
4650
async def async_update(self) -> None:
4751
""" Updates the data from API."""
4852

custom_components/rohlikcz/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,5 @@
88
"iot_class": "cloud_polling",
99
"issue_tracker": "https://github.com/dvejsada/HA-RohlikCZ/issues",
1010
"requirements": [],
11-
"version": "0.3.0"
11+
"version": "0.3.1"
1212
}

custom_components/rohlikcz/rohlik_api.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,7 @@ def mask_data(input_dict):
5151

5252

5353
class Product(TypedDict):
54-
"""
55-
TypedDict representing a Rohlik product to be added to cart.
5654

57-
Attributes:
58-
product_id (int): The unique identifier of the product
59-
quantity (int): The quantity of the product to add
60-
"""
6155
product_id: int
6256
quantity: int
6357

@@ -153,6 +147,30 @@ async def login(self, session):
153147
except RequestException as err:
154148
raise APIRequestFailedError(f"Cannot connect to website! Check your internet connection and try again: {err}")
155149

150+
async def logout(self, session) -> None:
151+
"""
152+
Log out from the Rohlik.cz service.
153+
:param session:
154+
:return:
155+
"""
156+
logout_url = f"{BASE_URL}/services/frontend-service/logout"
157+
158+
try:
159+
logout_response: Response = await self._run_in_executor(
160+
session.post,
161+
logout_url
162+
)
163+
164+
logout_response: dict = logout_response.json()
165+
166+
if logout_response["status"] != 200:
167+
raise RohlikczError(f"Unknown error occurred during logout: {logout_response}")
168+
else:
169+
_LOGGER.error("Successfully logged out from Rohlik.cz")
170+
171+
172+
except RequestException as err:
173+
raise APIRequestFailedError(f"Cannot connect to website! Check your internet connection and try again: {err}")
156174

157175
async def get_data(self):
158176
"""
@@ -173,7 +191,8 @@ async def get_data(self):
173191
"last_order": "/api/v3/orders/delivered?offset=0&limit=1",
174192
"premium_profile": "/services/frontend-service/premium/profile",
175193
"next_delivery_slot": "/services/frontend-service/timeslots-api/",
176-
"delivery_announcements": "/services/frontend-service/announcements/delivery"
194+
"delivery_announcements": "/services/frontend-service/announcements/delivery",
195+
"delivered_orders": "/api/v3/orders/delivered?offset=0&limit=50"
177196
}
178197

179198
result["login"] = await self.login(session)
@@ -211,6 +230,7 @@ async def get_data(self):
211230
raise APIRequestFailedError(f"Cannot connect to website! Check your internet connection and try again: {err}")
212231
finally:
213232
# Step 3: Close the session
233+
await self.logout(session)
214234
await self._run_in_executor(session.close)
215235

216236
async def add_to_cart(self, product_list: list[dict]) -> dict:
@@ -255,6 +275,7 @@ async def add_to_cart(self, product_list: list[dict]) -> dict:
255275
_LOGGER.error(f"Request failed: {err}")
256276
raise ValueError("Request failed")
257277
finally:
278+
await self.logout(session)
258279
await self._run_in_executor(session.close)
259280

260281
async def search_product(self, product_name: str, limit: int = 10, favourite: bool = False):
@@ -328,6 +349,7 @@ async def search_product(self, product_name: str, limit: int = 10, favourite: bo
328349
_LOGGER.error(f"Request failed: {err}")
329350
return None
330351
finally:
352+
await self.logout(session)
331353
await self._run_in_executor(session.close)
332354

333355
async def get_shopping_list(self, shopping_list_id=None) -> dict:
@@ -362,6 +384,7 @@ async def get_shopping_list(self, shopping_list_id=None) -> dict:
362384
_LOGGER.error(f"Request failed: {err}")
363385
raise ValueError("Request failed")
364386
finally:
387+
await self.logout(session)
365388
await self._run_in_executor(session.close)
366389

367390
async def get_cart_content(self, logged_in: bool = False, session = None) -> Dict:
@@ -389,6 +412,7 @@ async def get_cart_content(self, logged_in: bool = False, session = None) -> Dic
389412
raise ValueError("Request failed")
390413
finally:
391414
if not logged_in:
415+
await self.logout(session)
392416
await self._run_in_executor(session.close)
393417

394418
data = cart_content.get("data", {})
@@ -451,4 +475,5 @@ async def delete_from_cart(self, order_field_id: str) -> dict:
451475
_LOGGER.error(f"Error deleting item with orderFieldId {order_field_id}: {err}")
452476
raise APIRequestFailedError(f"Failed to delete item from cart: {err}")
453477
finally:
478+
await self.logout(session)
454479
await self._run_in_executor(session.close)

custom_components/rohlikcz/sensor.py

Lines changed: 70 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@
1616
from homeassistant.helpers.entity_platform import AddEntitiesCallback
1717
from .const import DOMAIN, ICON_UPDATE, ICON_CREDIT, ICON_NO_LIMIT, ICON_FREE_EXPRESS, ICON_DELIVERY, ICON_BAGS, \
1818
ICON_CART, ICON_ACCOUNT, ICON_EMAIL, ICON_PHONE, ICON_PREMIUM_DAYS, ICON_LAST_ORDER, ICON_NEXT_ORDER_SINCE, \
19-
ICON_NEXT_ORDER_TILL, ICON_INFO
19+
ICON_NEXT_ORDER_TILL, ICON_INFO, ICON_DELIVERY_TIME, ICON_MONTHLY_SPENT
2020
from .entity import BaseEntity
2121
from .hub import RohlikAccount
22+
from .utils import extract_delivery_datetime, calculate_current_month_orders_total
2223

2324
SCAN_INTERVAL = timedelta(seconds=600)
2425

@@ -46,7 +47,9 @@ async def async_setup_entry(
4647
LastOrder(rohlik_hub),
4748
NextOrderTill(rohlik_hub),
4849
NextOrderSince(rohlik_hub),
49-
DeliveryInfo(rohlik_hub)
50+
DeliveryInfo(rohlik_hub),
51+
DeliveryTime(rohlik_hub),
52+
MonthlySpent(rohlik_hub)
5053
]
5154

5255
if rohlik_hub.has_address:
@@ -78,135 +81,13 @@ def native_value(self) -> str | None:
7881
return None
7982

8083

81-
@staticmethod
82-
def extract_delivery_datetime(text: str) -> datetime | None:
83-
"""
84-
Extract delivery time information from various formatted strings and return a datetime object.
85-
86-
Handles three types of delivery messages:
87-
1. Time only (HH:MM): "delivery at 17:23"
88-
2. Date and time: "delivery on 26.4. at 08:00"
89-
3. Minutes until delivery: "delivery in approximately 3 minutes"
90-
91-
Args:
92-
text: HTML text containing delivery time information
93-
94-
Returns:
95-
A timezone-aware datetime object representing the delivery time, or None if no valid time found
96-
"""
97-
98-
# Replace Unicode escape sequences
99-
clean_text: str = text.encode('utf-8').decode('unicode_escape')
100-
101-
# Get plain text without HTML tags for pattern detection
102-
plain_text: str = re.sub(r'<[^>]+>', '', clean_text)
103-
104-
prague_tz = ZoneInfo('Europe/Prague')
105-
now = datetime.now(tz=prague_tz)
106-
current_year: int = now.year
107-
108-
# Check for Type 3: Minutes until delivery
109-
if re.search(r'(přibližně za|za)\s*.*\s*(minut|minuty|min)', plain_text, re.IGNORECASE):
110-
# Extract number of minutes from highlighted span
111-
minutes_pattern: re.Pattern = re.compile(r'<span[^>]*color:[^>]*>([0-9]+)</span>')
112-
113-
matches = re.finditer(minutes_pattern, clean_text)
114-
minutes_matches: list[str] = [match.group(1) for match in matches]
115-
116-
if minutes_matches:
117-
try:
118-
minutes: int = int(minutes_matches[0])
119-
# Calculate the estimated delivery time
120-
return now + timedelta(minutes=minutes)
121-
except ValueError:
122-
pass
123-
124-
# Check for Type 2: Date and time
125-
date_pattern = re.compile(r'<span[^>]*color:[^>]*>([0-9]{1,2}\.[0-9]{1,2}\.)</span>')
126-
time_pattern= re.compile(r'<span[^>]*color:[^>]*>([0-9]{1,2}:[0-9]{2})</span>')
127-
128-
matches_date = re.finditer(date_pattern, clean_text)
129-
date_matches= [match.group(1) for match in matches_date]
130-
131-
matches_time = re.finditer(time_pattern, clean_text)
132-
time_matches = [match.group(1) for match in matches_time]
133-
134-
if date_matches and time_matches:
135-
# We have both date and time
136-
try:
137-
date_str: str = date_matches[0] # e.g., "26.4."
138-
day, month = map(int, date_str.replace('.', ' ').split())
139-
140-
time_str: str = time_matches[0] # e.g., "08:00"
141-
hour, minute = map(int, time_str.split(':'))
142-
143-
# Create full delivery datetime
144-
delivery_dt = datetime(
145-
current_year, month, day, hour, minute,
146-
tzinfo=prague_tz
147-
)
148-
149-
return delivery_dt
150-
except (ValueError, IndexError):
151-
pass
152-
153-
# Check for Type 1: Time only
154-
if time_matches:
155-
try:
156-
time_str: str = time_matches[0] # e.g., "17:23"
157-
hour, minute = map(int, time_str.split(':'))
158-
159-
# Use today's date with the specified time
160-
today = now.date()
161-
162-
# If the time has already passed today, it might refer to tomorrow
163-
delivery_dt = datetime.combine(today, time(hour, minute))
164-
delivery_dt = delivery_dt.replace(tzinfo=prague_tz)
165-
166-
if delivery_dt < now:
167-
# Time already passed today, assume it's for tomorrow
168-
tomorrow = today + timedelta(days=1)
169-
delivery_dt = datetime.combine(tomorrow, time(hour, minute))
170-
delivery_dt = delivery_dt.replace(tzinfo=prague_tz)
171-
172-
return delivery_dt
173-
except (ValueError, IndexError):
174-
pass
175-
176-
# If no structured time information was found, try to extract any time mention
177-
# Generic time pattern search in the plain text
178-
plain_time_matches = re.findall(r'\b([0-9]{1,2}:[0-9]{2})\b', plain_text)
179-
if plain_time_matches:
180-
try:
181-
time_str: str = plain_time_matches[0]
182-
hour, minute = map(int, time_str.split(':'))
183-
184-
# Use today's date with the specified time
185-
today = now.date()
186-
187-
delivery_dt = datetime.combine(today, time(hour, minute))
188-
delivery_dt = delivery_dt.replace(tzinfo=prague_tz)
189-
190-
# If the time has already passed today, it might refer to tomorrow
191-
if delivery_dt < now:
192-
tomorrow = today + timedelta(days=1)
193-
delivery_dt = datetime.combine(tomorrow, time(hour, minute))
194-
delivery_dt = delivery_dt.replace(tzinfo=prague_tz)
195-
196-
return delivery_dt
197-
except (ValueError, IndexError):
198-
pass
199-
200-
# No valid time information found
201-
return None
202-
20384
@property
20485
def extra_state_attributes(self) -> Mapping[str, Any] | None:
20586
""" Get extra state attributes. """
20687
delivery_info: list = self._rohlik_account.data["delivery_announcements"]["data"]["announcements"]
20788
if len(delivery_info) > 0:
20889

209-
delivery_time = self.extract_delivery_datetime(delivery_info[0].get("content", ""))
90+
delivery_time = extract_delivery_datetime(delivery_info[0].get("content", ""))
21091

21192
if delivery_info[0].get("additionalContent", None):
21293
clean_text = delivery_info[0]["additionalContent"]
@@ -215,7 +96,7 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None:
21596
additional_info = None
21697

21798
return {
218-
"Delivery time - experimental": delivery_time,
99+
"Delivery time (deprecated, use new entity)": delivery_time,
219100
"Order Id": str(delivery_info[0].get("id")),
220101
"Updated At": datetime.fromisoformat(delivery_info[0].get("updatedAt")),
221102
"Title": delivery_info[0].get("title"),
@@ -235,6 +116,37 @@ async def async_added_to_hass(self) -> None:
235116
async def async_will_remove_from_hass(self) -> None:
236117
self._rohlik_account.remove_callback(self.async_write_ha_state)
237118

119+
class DeliveryTime(BaseEntity, SensorEntity):
120+
"""Sensor for showing delivery time."""
121+
122+
_attr_translation_key = "delivery_time"
123+
_attr_should_poll = False
124+
_attr_device_class = SensorDeviceClass.TIMESTAMP
125+
126+
@property
127+
def native_value(self) -> str | None:
128+
"""Returns time of delivery."""
129+
delivery_info: list = self._rohlik_account.data["delivery_announcements"]["data"]["announcements"]
130+
if len(delivery_info) > 0:
131+
132+
return extract_delivery_datetime(delivery_info[0].get("content", ""))
133+
134+
else:
135+
if self._rohlik_account.is_ordered:
136+
return self.native_value
137+
else:
138+
return None
139+
140+
141+
@property
142+
def icon(self) -> str:
143+
return ICON_DELIVERY_TIME
144+
145+
async def async_added_to_hass(self) -> None:
146+
self._rohlik_account.register_callback(self.async_write_ha_state)
147+
148+
async def async_will_remove_from_hass(self) -> None:
149+
self._rohlik_account.remove_callback(self.async_write_ha_state)
238150

239151
class FirstExpressSlot(BaseEntity, SensorEntity):
240152
"""Sensor for first available delivery."""
@@ -512,6 +424,29 @@ async def async_will_remove_from_hass(self) -> None:
512424
self._rohlik_account.remove_callback(self.async_write_ha_state)
513425

514426

427+
class MonthlySpent(BaseEntity, SensorEntity):
428+
"""Sensor for amount spent in current month."""
429+
430+
_attr_translation_key = "monthly_spent"
431+
_attr_should_poll = False
432+
433+
@property
434+
def native_value(self) -> float | None:
435+
"""Returns amount spend within last month."""
436+
_LOGGER.error(self._rohlik_account.data.get('delivered_orders', "[]"))
437+
return calculate_current_month_orders_total(self._rohlik_account.data.get('delivered_orders', []))
438+
439+
@property
440+
def icon(self) -> str:
441+
return ICON_MONTHLY_SPENT
442+
443+
async def async_added_to_hass(self) -> None:
444+
self._rohlik_account.register_callback(self.async_write_ha_state)
445+
446+
async def async_will_remove_from_hass(self) -> None:
447+
self._rohlik_account.remove_callback(self.async_write_ha_state)
448+
449+
515450
class NoLimitOrders(BaseEntity, SensorEntity):
516451
"""Sensor for remaining no limit orders."""
517452

@@ -753,11 +688,16 @@ class UpdateSensor(BaseEntity, SensorEntity):
753688
_attr_icon = ICON_UPDATE
754689
_attr_device_class = SensorDeviceClass.TIMESTAMP
755690

756-
def __init__(self, rohlik_account: RohlikAccount) -> None:
757-
super().__init__(rohlik_account)
758-
self._attr_native_value = datetime.now(tz=ZoneInfo("Europe/Prague"))
691+
@property
692+
def native_value(self) -> datetime:
693+
return datetime.now(tz=ZoneInfo("Europe/Prague"))
759694

760695
async def async_update(self) -> None:
761696
"""Calls regular update of data from API."""
762697
await self._rohlik_account.async_update()
763-
self._attr_native_value = datetime.now(tz=ZoneInfo("Europe/Prague"))
698+
699+
async def async_added_to_hass(self) -> None:
700+
self._rohlik_account.register_callback(self.async_write_ha_state)
701+
702+
async def async_will_remove_from_hass(self) -> None:
703+
self._rohlik_account.remove_callback(self.async_write_ha_state)

0 commit comments

Comments
 (0)