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
4 changes: 2 additions & 2 deletions custom_components/parcelapp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ParcelConfigEntry) -> bo
coordinator = ParcelUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()

entry.runtime_data = coordinator

# Store the coordinator in hass.data
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {"coordinator": coordinator}

# Forward entry setups only if not already forwarded
if "platforms" not in hass.data[DOMAIN][entry.entry_id]:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
hass.data[DOMAIN][entry.entry_id]["platforms"] = PLATFORMS

entry.runtime_data = coordinator
entry.async_on_unload(entry.add_update_listener(async_update_entry))
if not hass.data[DOMAIN].get("processed_cleanup"):
await cleanup_old_device(hass)
Expand Down
3 changes: 3 additions & 0 deletions custom_components/parcelapp/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
MIN_UPDATE_INTERVAL_SECONDS = 300
MAX_UPDATE_INTERVAL_SECONDS = 1800
CARRIER_CODE_ENDPOINT = "https://api.parcel.app/external/supported_carriers.json"
STORAGE_KEY = f"{DOMAIN}_cache"
STORAGE_VERSION = 1
DEFAULT_RETRY_AFTER_SECONDS = 300
DELIVERY_STATUS_CODES = {
-1: "None",
0: "Completed delivery.",
Expand Down
123 changes: 106 additions & 17 deletions custom_components/parcelapp/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,18 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.storage import Store
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .const import DOMAIN, PARCEL_URL, UPDATE_INTERVAL_SECONDS, CARRIER_CODE_ENDPOINT
from .const import (
DOMAIN,
PARCEL_URL,
UPDATE_INTERVAL_SECONDS,
CARRIER_CODE_ENDPOINT,
STORAGE_KEY,
STORAGE_VERSION,
DEFAULT_RETRY_AFTER_SECONDS,
)

_LOGGER = logging.getLogger(__name__)
type ParcelConfigEntry = ConfigEntry[ParcelUpdateCoordinator]
Expand All @@ -27,21 +36,64 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self._hass = hass
self.session = async_get_clientsession(self._hass)
self.carrier_codes = {"carrier_codes_updated": "", "carrier_codes": {}}
update_interval_seconds = entry.options.get(
self._configured_interval_seconds = entry.options.get(
"update_interval", UPDATE_INTERVAL_SECONDS
)
self._store = Store(hass, STORAGE_VERSION, f"{STORAGE_KEY}_{entry.entry_id}")
self._cached_data: dict[str, Any] | None = None
self._skip_next_update = False

super().__init__(
hass,
_LOGGER,
name=DOMAIN,
config_entry=entry,
update_interval=timedelta(seconds=update_interval_seconds),
update_interval=timedelta(seconds=self._configured_interval_seconds),
always_update=True,
)

async def _async_setup(self) -> None:
"""Load cached data from disk on startup."""
stored = await self._store.async_load()
if stored is None:
return

self._cached_data = stored

# Restore carrier codes from cache
cached_carrier_codes = stored.get("carrier_codes", {})
if cached_carrier_codes:
self.carrier_codes = {
"carrier_codes_updated": stored.get("carrier_codes_updated", ""),
"carrier_codes": cached_carrier_codes,
}

# If cache is fresh enough, skip the first API call
cached_timestamp = stored.get("utc_timestamp")
if cached_timestamp:
try:
cache_time = datetime.strptime(
cached_timestamp, "%Y-%m-%d %H:%M:%S.%f"
)
age_seconds = (datetime.now() - cache_time).total_seconds()
if age_seconds < self._configured_interval_seconds:
self._skip_next_update = True
_LOGGER.info(
"Cached data is fresh (%.0fs old), will skip first API call",
age_seconds,
)
except (ValueError, TypeError):
_LOGGER.debug(
"Could not parse cached timestamp, will fetch fresh data"
)

async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from the API and return the top value."""
if self._skip_next_update and self._cached_data is not None:
self._skip_next_update = False
_LOGGER.debug("Returning cached data, skipping API call")
return self._cached_data

API_URL = f"{PARCEL_URL}?filter_mode=recent"
carrier_codes_updated = self.carrier_codes["carrier_codes_updated"]
try:
Expand All @@ -51,24 +103,50 @@ async def _async_update_data(self) -> dict[str, Any]:
if updated < datetime.now() + timedelta(hours=-12):
try:
response = await self.session.get(CARRIER_CODE_ENDPOINT)
response.raise_for_status()
payload = await response.text()
carrier_codes_raw_json = json.loads(payload)
if response.status == 429:
_LOGGER.warning(
"Rate limited on carrier codes endpoint, keeping existing codes"
)
carrier_codes_raw_json = None
else:
response.raise_for_status()
payload = await response.text()
carrier_codes_raw_json = json.loads(payload)
except (aiohttp.ClientError, json.JSONDecodeError, TimeoutError):
carrier_codes_raw_json = {}
carrier_codes_raw_json.update(pholder="Placeholder")
carrier_codes_raw_json.update(none="None")
carrier_codes_json = {
"carrier_codes_updated": str(datetime.now()),
"carrier_codes": {},
}
carrier_codes_json["carrier_codes"] = carrier_codes_raw_json
self.carrier_codes = carrier_codes_json
else:
carrier_codes_json = self.carrier_codes
carrier_codes_raw_json = None
if carrier_codes_raw_json is not None:
carrier_codes_raw_json.update(pholder="Placeholder")
carrier_codes_raw_json.update(none="None")
carrier_codes_json = {
"carrier_codes_updated": str(datetime.now()),
"carrier_codes": {},
}
carrier_codes_json["carrier_codes"] = carrier_codes_raw_json
self.carrier_codes = carrier_codes_json
carrier_codes_json = self.carrier_codes
try:
headers = {"api-key": self.api_key, "Content-Type": "application/json"}
response = await self.session.get(API_URL, headers=headers)

if response.status == 429:
retry_after = DEFAULT_RETRY_AFTER_SECONDS
retry_header = response.headers.get("Retry-After")
if retry_header:
try:
retry_after = int(retry_header)
except ValueError:
pass
_LOGGER.warning(
"Parcel API rate limit hit (429). Backing off for %d seconds",
retry_after,
)
if self._cached_data is not None:
self.update_interval = timedelta(seconds=retry_after)
return self._cached_data
raise UpdateFailed(
"Rate limited by Parcel API (429) and no cached data available."
)

response.raise_for_status()
payload = await response.text()
payload_json = json.loads(payload)
Expand All @@ -79,7 +157,18 @@ async def _async_update_data(self) -> dict[str, Any]:
payload_json["utc_timestamp"] = datetime.now().strftime(
"%Y-%m-%d %H:%M:%S.%f"
)
self._cached_data = payload_json
await self._store.async_save(payload_json)
self.update_interval = timedelta(seconds=self._configured_interval_seconds)

return payload_json

except UpdateFailed:
raise
except Exception as err:
if self._cached_data is not None:
_LOGGER.warning(
"Error fetching data from API: %s. Returning cached data.", err
)
return self._cached_data
raise UpdateFailed(f"Error fetching data from API: {err}") from err
49 changes: 44 additions & 5 deletions custom_components/parcelapp/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .const import (
Expand All @@ -33,7 +34,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Parcel sensor platform from a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
coordinator = entry.runtime_data
async_add_entities(
[
RecentShipment(coordinator),
Expand All @@ -45,7 +46,7 @@ async def async_setup_entry(
)


class RecentShipment(CoordinatorEntity, SensorEntity):
class RecentShipment(CoordinatorEntity, RestoreEntity, SensorEntity):
"""Representation of a sensor that fetches the top value from an API."""

def __init__(self, coordinator: ParcelUpdateCoordinator) -> None:
Expand All @@ -58,6 +59,14 @@ def __init__(self, coordinator: ParcelUpdateCoordinator) -> None:
self._attr_icon = "mdi:package"
self._attr_state = None

async def async_added_to_hass(self) -> None:
"""Restore last known state when added to hass."""
await super().async_added_to_hass()
if (last_state := await self.async_get_last_state()) is not None:
self._attr_state = last_state.state
if last_state.attributes:
self._hass_custom_attributes = dict(last_state.attributes)

@property
def device_info(self):
"""Return device information."""
Expand Down Expand Up @@ -141,7 +150,7 @@ def _handle_coordinator_update(self) -> None:
self.async_write_ha_state()


class ActiveShipment(CoordinatorEntity, SensorEntity):
class ActiveShipment(CoordinatorEntity, RestoreEntity, SensorEntity):
"""Representation of a sensor that manipulates the data from the API, presents the next parcel due, and presents multiple attributes."""

_attr_state_class = SensorStateClass.MEASUREMENT
Expand All @@ -157,6 +166,17 @@ def __init__(self, coordinator: ParcelUpdateCoordinator) -> None:
self._attr_icon = "mdi:package"
self._attr_state = None

async def async_added_to_hass(self) -> None:
"""Restore last known state when added to hass."""
await super().async_added_to_hass()
if (last_state := await self.async_get_last_state()) is not None:
try:
self._attr_state = int(last_state.state)
except (ValueError, TypeError):
self._attr_state = last_state.state
if last_state.attributes:
self._hass_custom_attributes = dict(last_state.attributes)

@property
def device_info(self):
"""Return device information."""
Expand Down Expand Up @@ -410,7 +430,7 @@ def _handle_coordinator_update(self) -> None:
self.async_write_ha_state()


class CollectionShipment(CoordinatorEntity, SensorEntity):
class CollectionShipment(CoordinatorEntity, RestoreEntity, SensorEntity):
"""Representation of a sensor that reports any parcels currently ready for collection."""

# Disabled by default
Expand All @@ -428,6 +448,17 @@ def __init__(self, coordinator: ParcelUpdateCoordinator) -> None:
self._attr_icon = "mdi:package-up"
self._attr_state = None

async def async_added_to_hass(self) -> None:
"""Restore last known state when added to hass."""
await super().async_added_to_hass()
if (last_state := await self.async_get_last_state()) is not None:
try:
self._attr_state = int(last_state.state)
except (ValueError, TypeError):
self._attr_state = last_state.state
if last_state.attributes:
self._hass_custom_attributes = dict(last_state.attributes)

@property
def device_info(self):
"""Return device information."""
Expand Down Expand Up @@ -506,7 +537,7 @@ def _handle_coordinator_update(self) -> None:
self.async_write_ha_state()


class RawShipmentData(CoordinatorEntity, SensorEntity):
class RawShipmentData(CoordinatorEntity, RestoreEntity, SensorEntity):
"""Representation of a sensor that fetches the raw data from the API."""

# Disabled by default
Expand All @@ -522,6 +553,14 @@ def __init__(self, coordinator: ParcelUpdateCoordinator) -> None:
self._attr_icon = "mdi:package-down"
self._attr_state = None

async def async_added_to_hass(self) -> None:
"""Restore last known state when added to hass."""
await super().async_added_to_hass()
if (last_state := await self.async_get_last_state()) is not None:
self._attr_state = last_state.state
if last_state.attributes:
self._hass_custom_attributes = dict(last_state.attributes)

@property
def device_info(self):
"""Return device information."""
Expand Down
1 change: 1 addition & 0 deletions tests/test_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ async def test_parcel_update_coordinator(hass, aioclient_mock):
mock_entry = AsyncMock()
mock_entry.data = {"api_key": "test_api_key"}
mock_entry.options = {}
mock_entry.entry_id = "test_entry_coord"
mock_entry.async_on_unload = Mock()

# Initialize the coordinator
Expand Down
Loading
Loading