Skip to content

Commit 6b36986

Browse files
authored
Merge pull request #54 from eMeF1/feature/monthly-spent-accumulation
feat: implement HA-side accumulation for monthly spent sensor
2 parents f127f7a + 670e50d commit 6b36986

3 files changed

Lines changed: 236 additions & 27 deletions

File tree

custom_components/rohlikcz/binary_sensor.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
ICON_CALENDAR_REMOVE
1515
from .entity import BaseEntity
1616
from .hub import RohlikAccount
17+
from .utils import get_earliest_order
1718

1819
async def async_setup_entry(
1920
hass: HomeAssistant,
@@ -166,8 +167,8 @@ def is_on(self) -> bool | None:
166167
@property
167168
def extra_state_attributes(self) -> dict | None:
168169
next_orders = self._rohlik_account.data.get('next_order', [])
169-
if next_orders and len(next_orders) > 0:
170-
order = next_orders[0] # Get the first next order
170+
order = get_earliest_order(next_orders)
171+
if order:
171172
return {
172173
"order_data": order
173174
}

custom_components/rohlikcz/sensor.py

Lines changed: 160 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,18 @@
99
from datetime import timedelta, datetime, time
1010
from typing import Any
1111
from zoneinfo import ZoneInfo
12-
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass
12+
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass
1313
from homeassistant.config_entries import ConfigEntry
1414
from homeassistant.const import EntityCategory, STATE_UNAVAILABLE
1515
from homeassistant.core import HomeAssistant
1616
from homeassistant.helpers.entity_platform import AddEntitiesCallback
17+
from homeassistant.helpers.restore_state import RestoreEntity
1718
from .const import DOMAIN, ICON_UPDATE, ICON_CREDIT, ICON_NO_LIMIT, ICON_FREE_EXPRESS, ICON_DELIVERY, ICON_BAGS, \
1819
ICON_CART, ICON_ACCOUNT, ICON_EMAIL, ICON_PHONE, ICON_PREMIUM_DAYS, ICON_LAST_ORDER, ICON_NEXT_ORDER_SINCE, \
1920
ICON_NEXT_ORDER_TILL, ICON_INFO, ICON_DELIVERY_TIME, ICON_MONTHLY_SPENT
2021
from .entity import BaseEntity
2122
from .hub import RohlikAccount
22-
from .utils import extract_delivery_datetime, calculate_current_month_orders_total
23+
from .utils import extract_delivery_datetime, get_earliest_order, parse_delivery_datetime_string
2324

2425
SCAN_INTERVAL = timedelta(seconds=600)
2526

@@ -424,26 +425,163 @@ async def async_will_remove_from_hass(self) -> None:
424425
self._rohlik_account.remove_callback(self.async_write_ha_state)
425426

426427

427-
class MonthlySpent(BaseEntity, SensorEntity):
428-
"""Sensor for amount spent in current month."""
428+
class MonthlySpent(BaseEntity, SensorEntity, RestoreEntity):
429+
"""Sensor for amount spent in current month with HA-side accumulation.
430+
431+
Only tracks orders that are delivered and closed (have final price).
432+
Orders from the delivered_orders endpoint should all be finalized.
433+
Uses Home Assistant's restore state to persist monthly totals across restarts.
434+
"""
429435

430436
_attr_translation_key = "monthly_spent"
431437
_attr_should_poll = False
438+
_attr_state_class = SensorStateClass.TOTAL
439+
440+
def __init__(self, rohlik_account: RohlikAccount) -> None:
441+
super().__init__(rohlik_account)
442+
self._monthly_total: float = 0.0
443+
self._processed_orders: set[str] = set() # Store order IDs
444+
self._current_month: str = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y-%m")
445+
self._last_reset: datetime | None = None
446+
447+
def _is_order_final(self, order: dict) -> bool:
448+
"""
449+
Verify order has a final price.
450+
451+
Since orders come from the 'delivered_orders' endpoint, they should be finalized.
452+
We verify by checking that priceComposition exists and has a valid amount.
453+
"""
454+
# Check if priceComposition exists
455+
price_comp = order.get('priceComposition')
456+
if not price_comp:
457+
return False
458+
459+
# Check if total exists
460+
total = price_comp.get('total')
461+
if not total:
462+
return False
463+
464+
# Check if amount exists and is a valid number
465+
amount = total.get('amount')
466+
if amount is None:
467+
return False
468+
469+
# Verify it's a valid number
470+
try:
471+
float(amount)
472+
return True
473+
except (ValueError, TypeError):
474+
return False
475+
476+
async def async_added_to_hass(self) -> None:
477+
"""Restore state when added to HA."""
478+
await super().async_added_to_hass()
479+
480+
if (last_state := await self.async_get_last_state()) is not None:
481+
self._monthly_total = last_state.attributes.get("monthly_total", 0.0)
482+
self._processed_orders = set(last_state.attributes.get("processed_orders", []))
483+
self._current_month = last_state.attributes.get("current_month", datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y-%m"))
484+
if last_reset_str := last_state.attributes.get("last_reset"):
485+
self._last_reset = datetime.fromisoformat(last_reset_str)
486+
487+
self._check_and_reset_month()
488+
self._process_new_orders()
489+
490+
self._rohlik_account.register_callback(self._handle_coordinator_update)
491+
492+
def _check_and_reset_month(self) -> None:
493+
"""Reset total if month changed."""
494+
current_month = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y-%m")
495+
if current_month != self._current_month:
496+
_LOGGER.info(f"Month changed from {self._current_month} to {current_month}, resetting monthly total")
497+
self._monthly_total = 0.0
498+
self._processed_orders = set()
499+
self._current_month = current_month
500+
self._last_reset = datetime.now(ZoneInfo("Europe/Prague"))
501+
502+
def _process_new_orders(self) -> None:
503+
"""Process new orders and add to total.
504+
505+
Only processes orders that are delivered and closed (have final price).
506+
Uses order ID for unique identification.
507+
"""
508+
orders = self._rohlik_account.data.get('delivered_orders', [])
509+
if not orders:
510+
return
511+
512+
current_month_pattern = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y-%m-")
513+
new_orders_count = 0
514+
515+
for order in orders:
516+
try:
517+
order_time = order.get('orderTime', '')
518+
519+
# Only process orders from current month
520+
if current_month_pattern not in order_time:
521+
continue
522+
523+
# Verify order has final price (delivered and closed)
524+
if not self._is_order_final(order):
525+
_LOGGER.debug(f"Order {order.get('id')} does not have final price, skipping")
526+
continue
527+
528+
# Get order ID (unique identifier)
529+
order_id = order.get('id')
530+
if not order_id:
531+
_LOGGER.warning(f"Order missing ID, skipping: {order.get('orderTime')}")
532+
continue
533+
534+
order_key = str(order_id)
535+
536+
# Skip if already processed
537+
if order_key in self._processed_orders:
538+
continue
539+
540+
# Get the final price
541+
amount = float(order['priceComposition']['total']['amount'])
542+
543+
# Add to total and mark as processed
544+
self._monthly_total += amount
545+
self._processed_orders.add(order_key)
546+
new_orders_count += 1
547+
548+
_LOGGER.debug(f"Added order {order_id} with amount {amount} CZK. New total: {self._monthly_total} CZK")
549+
550+
except (KeyError, ValueError, TypeError) as e:
551+
_LOGGER.warning(f"Skipping order due to error: {e}, order ID: {order.get('id')}")
552+
continue
553+
554+
if new_orders_count > 0:
555+
_LOGGER.info(f"Processed {new_orders_count} new order(s). Monthly total: {self._monthly_total} CZK")
556+
557+
async def _handle_coordinator_update(self) -> None:
558+
"""Handle coordinator updates by processing new orders and updating state."""
559+
self._check_and_reset_month()
560+
self._process_new_orders()
561+
await self.async_write_ha_state()
432562

433563
@property
434564
def native_value(self) -> float | None:
435-
"""Returns amount spend within last month."""
436-
return calculate_current_month_orders_total(self._rohlik_account.data.get('delivered_orders', []))
565+
"""Returns amount spent in current month."""
566+
return self._monthly_total if self._monthly_total > 0 else 0.0
567+
568+
@property
569+
def extra_state_attributes(self) -> Mapping[str, Any] | None:
570+
"""Store state for restoration."""
571+
return {
572+
"monthly_total": self._monthly_total,
573+
"processed_orders": list(self._processed_orders),
574+
"current_month": self._current_month,
575+
"last_reset": self._last_reset.isoformat() if self._last_reset else None,
576+
"processed_count": len(self._processed_orders)
577+
}
437578

438579
@property
439580
def icon(self) -> str:
440581
return ICON_MONTHLY_SPENT
441582

442-
async def async_added_to_hass(self) -> None:
443-
self._rohlik_account.register_callback(self.async_write_ha_state)
444-
445583
async def async_will_remove_from_hass(self) -> None:
446-
self._rohlik_account.remove_callback(self.async_write_ha_state)
584+
self._rohlik_account.remove_callback(self._handle_coordinator_update)
447585

448586

449587
class NoLimitOrders(BaseEntity, SensorEntity):
@@ -598,13 +736,12 @@ class NextOrderSince(BaseEntity, SensorEntity):
598736

599737
@property
600738
def native_value(self) -> datetime | None:
601-
"""Returns remaining orders without limit."""
602-
if len(self._rohlik_account.data['next_order']) > 0:
603-
slot_start = datetime.strptime(self._rohlik_account.data["next_order"][0].get("deliverySlot", {}).get("since", None),
604-
"%Y-%m-%dT%H:%M:%S.%f%z")
605-
return slot_start
606-
else:
607-
return None
739+
"""Returns start of delivery window for the earliest order."""
740+
earliest_order = get_earliest_order(self._rohlik_account.data.get('next_order', []))
741+
if earliest_order:
742+
since_str = earliest_order.get("deliverySlot", {}).get("since", None)
743+
return parse_delivery_datetime_string(since_str)
744+
return None
608745

609746
@property
610747
def icon(self) -> str:
@@ -625,13 +762,12 @@ class NextOrderTill(BaseEntity, SensorEntity):
625762

626763
@property
627764
def native_value(self) -> datetime | None:
628-
"""Returns remaining orders without limit."""
629-
if len(self._rohlik_account.data['next_order']) > 0:
630-
slot_start = datetime.strptime(self._rohlik_account.data["next_order"][0].get("deliverySlot", {}).get("till", None),
631-
"%Y-%m-%dT%H:%M:%S.%f%z")
632-
return slot_start
633-
else:
634-
return None
765+
"""Returns end of delivery window for the earliest order."""
766+
earliest_order = get_earliest_order(self._rohlik_account.data.get('next_order', []))
767+
if earliest_order:
768+
till_str = earliest_order.get("deliverySlot", {}).get("till", None)
769+
return parse_delivery_datetime_string(till_str)
770+
return None
635771

636772
@property
637773
def icon(self) -> str:

custom_components/rohlikcz/utils.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,4 +159,76 @@ def extract_delivery_datetime(text: str) -> datetime | None:
159159
pass
160160

161161
# No valid time information found
162-
return None
162+
return None
163+
164+
165+
def parse_delivery_datetime_string(datetime_str: str) -> datetime | None:
166+
"""
167+
Parse a delivery datetime string with fallback for different formats.
168+
169+
Args:
170+
datetime_str (str): Datetime string to parse
171+
172+
Returns:
173+
datetime: Parsed datetime object, or None if parsing fails
174+
"""
175+
if datetime_str is None:
176+
return None
177+
178+
try:
179+
# Try parsing with microseconds first
180+
return datetime.strptime(datetime_str, "%Y-%m-%dT%H:%M:%S.%f%z")
181+
except ValueError:
182+
# Try without microseconds if the format doesn't match
183+
try:
184+
return datetime.strptime(datetime_str, "%Y-%m-%dT%H:%M:%S%z")
185+
except ValueError:
186+
return None
187+
188+
189+
def get_earliest_order(orders: list) -> dict | None:
190+
"""
191+
Find the order with the earliest delivery time from a list of orders.
192+
193+
Args:
194+
orders (list): List of order dictionaries, each containing a 'deliverySlot' with 'since' field
195+
196+
Returns:
197+
dict: The order with the earliest delivery time, or None if no valid order found
198+
"""
199+
if not orders:
200+
return None
201+
202+
earliest_order = None
203+
earliest_time = None
204+
205+
for order in orders:
206+
try:
207+
# Extract delivery slot and since time
208+
delivery_slot = order.get("deliverySlot", {})
209+
since_str = delivery_slot.get("since", None)
210+
211+
if since_str is None:
212+
continue
213+
214+
# Parse the datetime string (format: "%Y-%m-%dT%H:%M:%S.%f%z")
215+
try:
216+
delivery_time = datetime.strptime(since_str, "%Y-%m-%dT%H:%M:%S.%f%z")
217+
except ValueError:
218+
# Try without microseconds if the format doesn't match
219+
try:
220+
delivery_time = datetime.strptime(since_str, "%Y-%m-%dT%H:%M:%S%z")
221+
except ValueError:
222+
# Skip orders with invalid date format
223+
continue
224+
225+
# Check if this is the earliest order so far
226+
if earliest_time is None or delivery_time < earliest_time:
227+
earliest_time = delivery_time
228+
earliest_order = order
229+
230+
except (KeyError, TypeError, AttributeError):
231+
# Skip orders with missing or invalid structure
232+
continue
233+
234+
return earliest_order

0 commit comments

Comments
 (0)