Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions custom_components/rohlikcz/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
ICON_CALENDAR_REMOVE
from .entity import BaseEntity
from .hub import RohlikAccount
from .utils import get_earliest_order

async def async_setup_entry(
hass: HomeAssistant,
Expand Down Expand Up @@ -166,8 +167,8 @@ def is_on(self) -> bool | None:
@property
def extra_state_attributes(self) -> dict | None:
next_orders = self._rohlik_account.data.get('next_order', [])
if next_orders and len(next_orders) > 0:
order = next_orders[0] # Get the first next order
order = get_earliest_order(next_orders)
if order:
return {
"order_data": order
}
Expand Down
184 changes: 160 additions & 24 deletions custom_components/rohlikcz/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,18 @@
from datetime import timedelta, datetime, time
from typing import Any
from zoneinfo import ZoneInfo
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import DOMAIN, ICON_UPDATE, ICON_CREDIT, ICON_NO_LIMIT, ICON_FREE_EXPRESS, ICON_DELIVERY, ICON_BAGS, \
ICON_CART, ICON_ACCOUNT, ICON_EMAIL, ICON_PHONE, ICON_PREMIUM_DAYS, ICON_LAST_ORDER, ICON_NEXT_ORDER_SINCE, \
ICON_NEXT_ORDER_TILL, ICON_INFO, ICON_DELIVERY_TIME, ICON_MONTHLY_SPENT
from .entity import BaseEntity
from .hub import RohlikAccount
from .utils import extract_delivery_datetime, calculate_current_month_orders_total
from .utils import extract_delivery_datetime, get_earliest_order, parse_delivery_datetime_string

SCAN_INTERVAL = timedelta(seconds=600)

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


class MonthlySpent(BaseEntity, SensorEntity):
"""Sensor for amount spent in current month."""
class MonthlySpent(BaseEntity, SensorEntity, RestoreEntity):
"""Sensor for amount spent in current month with HA-side accumulation.

Only tracks orders that are delivered and closed (have final price).
Orders from the delivered_orders endpoint should all be finalized.
Uses Home Assistant's restore state to persist monthly totals across restarts.
"""

_attr_translation_key = "monthly_spent"
_attr_should_poll = False
_attr_state_class = SensorStateClass.TOTAL

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._last_reset: datetime | None = None

def _is_order_final(self, order: dict) -> bool:
"""
Verify order has a final price.

Since orders come from the 'delivered_orders' endpoint, they should be finalized.
We verify by checking that priceComposition exists and has a valid amount.
"""
# Check if priceComposition exists
price_comp = order.get('priceComposition')
if not price_comp:
return False

# Check if total exists
total = price_comp.get('total')
if not total:
return False

# Check if amount exists and is a valid number
amount = total.get('amount')
if amount is None:
return False

# Verify it's a valid number
try:
float(amount)
return True
except (ValueError, TypeError):
return False

async def async_added_to_hass(self) -> None:
"""Restore state when added to HA."""
await super().async_added_to_hass()

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"))
if last_reset_str := last_state.attributes.get("last_reset"):
self._last_reset = datetime.fromisoformat(last_reset_str)

self._check_and_reset_month()
self._process_new_orders()

self._rohlik_account.register_callback(self._handle_coordinator_update)

def _check_and_reset_month(self) -> None:
"""Reset total if month changed."""
current_month = datetime.now(ZoneInfo("Europe/Prague")).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"))
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timezone inconsistency: In _check_and_reset_month(), self._last_reset is set using datetime.now(ZoneInfo("Europe/Prague")) (line 500), but in __init__() and when checking month boundaries, the code uses datetime.now() without timezone (lines 444, 494, 512). This could lead to inconsistent datetime comparisons or timezone-related issues. Consider using timezone-aware datetime consistently throughout, particularly datetime.now(ZoneInfo("Europe/Prague")) for all datetime.now() calls in this class.

Copilot uses AI. Check for mistakes.

def _process_new_orders(self) -> None:
"""Process new orders and add to total.

Only processes orders that are delivered and closed (have final price).
Uses order ID for unique identification.
"""
orders = self._rohlik_account.data.get('delivered_orders', [])
if not orders:
return

current_month_pattern = datetime.now(ZoneInfo("Europe/Prague")).strftime("%Y-%m-")
new_orders_count = 0

for order in orders:
try:
order_time = order.get('orderTime', '')

# Only process orders from current month
if current_month_pattern not in order_time:
continue

# Verify order has final price (delivered and closed)
if not self._is_order_final(order):
_LOGGER.debug(f"Order {order.get('id')} does not have final price, skipping")
continue

# Get order ID (unique identifier)
order_id = order.get('id')
if not order_id:
_LOGGER.warning(f"Order missing ID, skipping: {order.get('orderTime')}")
continue

order_key = str(order_id)

# Skip if already processed
if order_key in self._processed_orders:
continue

# Get the final price
amount = float(order['priceComposition']['total']['amount'])

# Add to total and mark as processed
self._monthly_total += amount
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")

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")

async def _handle_coordinator_update(self) -> None:
"""Handle coordinator updates by processing new orders and updating state."""
self._check_and_reset_month()
self._process_new_orders()
await self.async_write_ha_state()

@property
def native_value(self) -> float | None:
"""Returns amount spend within last month."""
return calculate_current_month_orders_total(self._rohlik_account.data.get('delivered_orders', []))
"""Returns amount spent in current month."""
return self._monthly_total if self._monthly_total > 0 else 0.0

@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Store state for restoration."""
return {
"monthly_total": self._monthly_total,
"processed_orders": list(self._processed_orders),
"current_month": self._current_month,
"last_reset": self._last_reset.isoformat() if self._last_reset else None,
"processed_count": len(self._processed_orders)
}

@property
def icon(self) -> str:
return ICON_MONTHLY_SPENT

async def async_added_to_hass(self) -> None:
self._rohlik_account.register_callback(self.async_write_ha_state)

async def async_will_remove_from_hass(self) -> None:
self._rohlik_account.remove_callback(self.async_write_ha_state)
self._rohlik_account.remove_callback(self._handle_coordinator_update)


class NoLimitOrders(BaseEntity, SensorEntity):
Expand Down Expand Up @@ -598,13 +736,12 @@ class NextOrderSince(BaseEntity, SensorEntity):

@property
def native_value(self) -> datetime | None:
"""Returns remaining orders without limit."""
if len(self._rohlik_account.data['next_order']) > 0:
slot_start = datetime.strptime(self._rohlik_account.data["next_order"][0].get("deliverySlot", {}).get("since", None),
"%Y-%m-%dT%H:%M:%S.%f%z")
return slot_start
else:
return None
"""Returns start of delivery window for the earliest order."""
earliest_order = get_earliest_order(self._rohlik_account.data.get('next_order', []))
if earliest_order:
since_str = earliest_order.get("deliverySlot", {}).get("since", None)
return parse_delivery_datetime_string(since_str)
return None

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

@property
def native_value(self) -> datetime | None:
"""Returns remaining orders without limit."""
if len(self._rohlik_account.data['next_order']) > 0:
slot_start = datetime.strptime(self._rohlik_account.data["next_order"][0].get("deliverySlot", {}).get("till", None),
"%Y-%m-%dT%H:%M:%S.%f%z")
return slot_start
else:
return None
"""Returns end of delivery window for the earliest order."""
earliest_order = get_earliest_order(self._rohlik_account.data.get('next_order', []))
if earliest_order:
till_str = earliest_order.get("deliverySlot", {}).get("till", None)
return parse_delivery_datetime_string(till_str)
return None

@property
def icon(self) -> str:
Expand Down
74 changes: 73 additions & 1 deletion custom_components/rohlikcz/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,76 @@ def extract_delivery_datetime(text: str) -> datetime | None:
pass

# No valid time information found
return None
return None


def parse_delivery_datetime_string(datetime_str: str) -> datetime | None:
"""
Parse a delivery datetime string with fallback for different formats.

Args:
datetime_str (str): Datetime string to parse

Returns:
datetime: Parsed datetime object, or None if parsing fails
"""
if datetime_str is None:
return None

try:
# Try parsing with microseconds first
return datetime.strptime(datetime_str, "%Y-%m-%dT%H:%M:%S.%f%z")
except ValueError:
# Try without microseconds if the format doesn't match
try:
return datetime.strptime(datetime_str, "%Y-%m-%dT%H:%M:%S%z")
except ValueError:
return None


def get_earliest_order(orders: list) -> dict | None:
"""
Find the order with the earliest delivery time from a list of orders.

Args:
orders (list): List of order dictionaries, each containing a 'deliverySlot' with 'since' field

Returns:
dict: The order with the earliest delivery time, or None if no valid order found
"""
if not orders:
return None

earliest_order = None
earliest_time = None

for order in orders:
try:
# Extract delivery slot and since time
delivery_slot = order.get("deliverySlot", {})
since_str = delivery_slot.get("since", None)

if since_str is None:
continue

# Parse the datetime string (format: "%Y-%m-%dT%H:%M:%S.%f%z")
try:
delivery_time = datetime.strptime(since_str, "%Y-%m-%dT%H:%M:%S.%f%z")
except ValueError:
# Try without microseconds if the format doesn't match
try:
delivery_time = datetime.strptime(since_str, "%Y-%m-%dT%H:%M:%S%z")
except ValueError:
# Skip orders with invalid date format
continue

# Check if this is the earliest order so far
if earliest_time is None or delivery_time < earliest_time:
earliest_time = delivery_time
earliest_order = order

except (KeyError, TypeError, AttributeError):
# Skip orders with missing or invalid structure
continue

return earliest_order