diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 80079ce..42cac4a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,7 @@ name: Create Release ZIP on: release: - types: [created, published] + types: [published] permissions: contents: write diff --git a/custom_components/parcelapp/config_flow.py b/custom_components/parcelapp/config_flow.py index 2babe8a..ea0e035 100644 --- a/custom_components/parcelapp/config_flow.py +++ b/custom_components/parcelapp/config_flow.py @@ -8,8 +8,15 @@ from homeassistant import config_entries from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector -from .const import DOMAIN, PARCEL_URL +from .const import ( + DOMAIN, + PARCEL_URL, + UPDATE_INTERVAL_SECONDS, + MIN_UPDATE_INTERVAL_SECONDS, + MAX_UPDATE_INTERVAL_SECONDS, +) _LOGGER = logging.getLogger(__name__) @@ -105,6 +112,8 @@ async def async_step_init( # Validate the new API key if it was changed new_api_key = user_input.get("api_key") new_account_token = user_input.get("account_token") + new_update_interval_minutes = user_input.get("update_interval_minutes", UPDATE_INTERVAL_SECONDS // 60) + new_update_interval = new_update_interval_minutes * 60 # Check if the API key or account token has changed if new_api_key != self.config_entry.data.get( @@ -140,13 +149,23 @@ async def async_step_init( errors={"base": "cannot_connect"}, ) - # Save the updated options - return self.async_create_entry(data=user_input) + return self.async_create_entry( + data={ + "api_key": new_api_key, + "account_token": new_account_token, + "update_interval": new_update_interval, + } + ) return self.async_show_form(step_id="init", data_schema=self._create_schema()) def _create_schema(self) -> vol.Schema: """Create the form schema for updating the API key and optional account token.""" + current_interval_seconds = self.config_entry.options.get( + "update_interval", UPDATE_INTERVAL_SECONDS + ) + current_interval_minutes = current_interval_seconds // 60 + return vol.Schema( { vol.Required( @@ -157,6 +176,18 @@ def _create_schema(self) -> vol.Schema: "account_token", default=self.config_entry.data.get("account_token", ""), ): str, + vol.Optional( + "update_interval_minutes", + default=current_interval_minutes, + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=MIN_UPDATE_INTERVAL_SECONDS // 60, + max=MAX_UPDATE_INTERVAL_SECONDS // 60, + step=5, + mode=selector.NumberSelectorMode.SLIDER, + unit_of_measurement="minutes", + ) + ), } ) diff --git a/custom_components/parcelapp/const.py b/custom_components/parcelapp/const.py index 2fca6d7..dfb32f4 100644 --- a/custom_components/parcelapp/const.py +++ b/custom_components/parcelapp/const.py @@ -2,7 +2,9 @@ DOMAIN = "parcelapp" PARCEL_URL = "https://api.parcel.app/external/deliveries/" -UPDATE_INTERVAL_SECONDS = 300 # RATE LIMIT IS 20 PER HOUR +UPDATE_INTERVAL_SECONDS = 300 +MIN_UPDATE_INTERVAL_SECONDS = 300 +MAX_UPDATE_INTERVAL_SECONDS = 1800 CARRIER_CODE_ENDPOINT = "https://api.parcel.app/external/supported_carriers.json" DELIVERY_STATUS_CODES = { -1: "None", diff --git a/custom_components/parcelapp/coordinator.py b/custom_components/parcelapp/coordinator.py index cc4525e..f757c6e 100644 --- a/custom_components/parcelapp/coordinator.py +++ b/custom_components/parcelapp/coordinator.py @@ -25,13 +25,16 @@ 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( + "update_interval", UPDATE_INTERVAL_SECONDS + ) super().__init__( hass, _LOGGER, name=DOMAIN, config_entry=entry, - update_interval=timedelta(seconds=UPDATE_INTERVAL_SECONDS), + update_interval=timedelta(seconds=update_interval_seconds), always_update=True, ) diff --git a/custom_components/parcelapp/manifest.json b/custom_components/parcelapp/manifest.json index 5193292..e053946 100644 --- a/custom_components/parcelapp/manifest.json +++ b/custom_components/parcelapp/manifest.json @@ -11,5 +11,5 @@ "requests>=2.32.3", "python-dateutil>=2.9.0" ], - "version": "1.7.2" + "version": "1.7.3" } diff --git a/custom_components/parcelapp/sensor.py b/custom_components/parcelapp/sensor.py index 8ebbc6c..ea3cb92 100644 --- a/custom_components/parcelapp/sensor.py +++ b/custom_components/parcelapp/sensor.py @@ -9,10 +9,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( DOMAIN, - UPDATE_INTERVAL_SECONDS, RETURN_CODES, DELIVERY_STATUS_CODES, Shipment, @@ -25,7 +25,6 @@ PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -SCAN_INTERVAL = timedelta(seconds=UPDATE_INTERVAL_SECONDS) async def async_setup_entry( @@ -46,12 +45,12 @@ async def async_setup_entry( ) -class RecentShipment(SensorEntity): +class RecentShipment(CoordinatorEntity, SensorEntity): """Representation of a sensor that fetches the top value from an API.""" def __init__(self, coordinator: ParcelUpdateCoordinator) -> None: """Initialize the sensor.""" - self.coordinator = coordinator + super().__init__(coordinator) self._hass_custom_attributes = {} self._attr_name = "Parcel Recent Shipment" self._attr_unique_id = f"{coordinator.config_entry.entry_id}_recent_shipment" @@ -80,15 +79,14 @@ def extra_state_attributes(self): """Return the state attributes of the sensor.""" return self._hass_custom_attributes - async def async_update(self) -> None: - """Fetch the latest data from the coordinator.""" - await self.coordinator.async_request_refresh() + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" parcel_api_data = self.coordinator.data if parcel_api_data["deliveries"] == []: self._attr_state = "No parcels for now.." self._attr_icon = "mdi:close-circle" - self._hass_custom_attributes = EMPTY_ATTRIBUTES + self._hass_custom_attributes = EMPTY_ATTRIBUTES.copy() elif parcel_api_data["deliveries"]: data = parcel_api_data["deliveries"] carrier_codes = parcel_api_data["carrier_codes"] @@ -135,13 +133,15 @@ async def async_update(self) -> None: } self._hass_custom_attributes = attributes + self.async_write_ha_state() -class ActiveShipment(SensorEntity): + +class ActiveShipment(CoordinatorEntity, SensorEntity): """Representation of a sensor that manipulates the data from the API, presents the next parcel due, and presents multiple attributes.""" def __init__(self, coordinator: ParcelUpdateCoordinator) -> None: """Initialize the sensor.""" - self.coordinator = coordinator + super().__init__(coordinator) self._hass_custom_attributes = {} self._attr_name = "Parcel Active Shipment" self._attr_unique_id = f"{coordinator.config_entry.entry_id}_active_shipment" @@ -170,9 +170,8 @@ def extra_state_attributes(self): """Return the state attributes of the sensor.""" return self._hass_custom_attributes - async def async_update(self) -> None: - """Fetch the latest data from the coordinator.""" - await self.coordinator.async_request_refresh() + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" parcel_api_data = self.coordinator.data shipments = [] active_shipments = [] @@ -183,7 +182,7 @@ async def async_update(self) -> None: if parcel_api_data["deliveries"] == []: self._attr_state = "No parcels for now.." self._attr_icon = "mdi:close-circle" - self._hass_custom_attributes = EMPTY_ATTRIBUTES + self._hass_custom_attributes = EMPTY_ATTRIBUTES.copy() self._hass_custom_attributes["delivered_today"] = 0 elif parcel_api_data["deliveries"]: data = parcel_api_data["deliveries"] @@ -397,7 +396,10 @@ async def async_update(self) -> None: "delivered_today": delivered_today, } -class CollectionShipment(SensorEntity): + self.async_write_ha_state() + + +class CollectionShipment(CoordinatorEntity, SensorEntity): """Representation of a sensor that reports any parcels currently ready for collection.""" # Disabled by default @@ -405,7 +407,7 @@ class CollectionShipment(SensorEntity): def __init__(self, coordinator: ParcelUpdateCoordinator) -> None: """Initialize the sensor.""" - self.coordinator = coordinator + super().__init__(coordinator) self._hass_custom_attributes = {} self._attr_name = "Parcel Collection Shipment" self._attr_unique_id = f"{coordinator.config_entry.entry_id}_collection_shipment" @@ -434,9 +436,8 @@ def extra_state_attributes(self): """Return the state attributes of the sensor.""" return self._hass_custom_attributes - async def async_update(self) -> None: - """Fetch the latest data from the coordinator.""" - await self.coordinator.async_request_refresh() + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" parcel_api_data = self.coordinator.data collectable_shipments = [] @@ -485,11 +486,14 @@ async def async_update(self) -> None: } else: self._attr_state = 0 - self._hass_custom_attributes = { + self._hass_custom_attributes = { "collectable_shipments": collectable_shipments, } -class RawShipmentData(SensorEntity): + self.async_write_ha_state() + + +class RawShipmentData(CoordinatorEntity, SensorEntity): """Representation of a sensor that fetches the raw data from the API.""" # Disabled by default @@ -497,7 +501,7 @@ class RawShipmentData(SensorEntity): def __init__(self, coordinator: ParcelUpdateCoordinator) -> None: """Initialize the sensor.""" - self.coordinator = coordinator + super().__init__(coordinator) self._hass_custom_attributes = {} self._attr_name = "Parcel Raw Shipment Data" self._attr_unique_id = f"{coordinator.config_entry.entry_id}_raw_shipment_data" @@ -526,9 +530,8 @@ def extra_state_attributes(self): """Return the state attributes of the sensor.""" return self._hass_custom_attributes - async def async_update(self) -> None: - """Fetch the latest data from the coordinator.""" - await self.coordinator.async_request_refresh() + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" parcel_api_data = self.coordinator.data if parcel_api_data: @@ -539,3 +542,5 @@ async def async_update(self) -> None: "carrier_codes_updated": parcel_api_data["carrier_codes_updated"], "utc_timestamp": parcel_api_data["utc_timestamp"], } + + self.async_write_ha_state() diff --git a/custom_components/parcelapp/services.py b/custom_components/parcelapp/services.py index 8e50bd6..8dd3c5e 100644 --- a/custom_components/parcelapp/services.py +++ b/custom_components/parcelapp/services.py @@ -145,7 +145,7 @@ def get_http_error_message( if status_code == 429: return ( "Rate limit exceeded (HTTP 429). " - "The Parcel App API allows 20 requests per day. Please try again later." + "The Parcel App API allows 20 requests per hour. Please try again later." ) if status_code >= 500: diff --git a/custom_components/parcelapp/translations/en.json b/custom_components/parcelapp/translations/en.json index bb6e517..935135c 100644 --- a/custom_components/parcelapp/translations/en.json +++ b/custom_components/parcelapp/translations/en.json @@ -18,7 +18,11 @@ "description": "Update the API Key and Account Token for ParcelApp", "data": { "api_key": "API Key (Required)", - "account_token": "Account Token (Optional) | BETA" + "account_token": "Account Token (Optional) | BETA", + "update_interval_minutes": "Update Interval" + }, + "data_description": { + "update_interval_minutes": "How often to check for parcel updates (in minutes). API rate limit: 20 requests per hour." } } } diff --git a/setup.cfg b/setup.cfg index 90d75da..5597780 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,3 @@ [tool:pytest] -asyncio_mode = auto \ No newline at end of file +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function \ No newline at end of file diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 30a04e8..dce9d95 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -1,7 +1,7 @@ """Test sensor for simple integration.""" import pytest -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock from pathlib import Path import json from datetime import datetime @@ -36,6 +36,8 @@ async def test_parcel_update_coordinator(hass, aioclient_mock): # Mock ConfigEntry mock_entry = AsyncMock() mock_entry.data = {"api_key": "test_api_key"} + mock_entry.options = {} + mock_entry.async_on_unload = Mock() # Initialize the coordinator coordinator = ParcelUpdateCoordinator(hass, mock_entry) diff --git a/tests/test_sensors.py b/tests/test_sensors.py index af23072..00b7ff3 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -1,7 +1,7 @@ import pytest import json from datetime import datetime, timedelta, date -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock from pathlib import Path from custom_components.parcelapp.sensor import RecentShipment, ActiveShipment, CollectionShipment from custom_components.parcelapp.coordinator import ParcelUpdateCoordinator @@ -48,7 +48,7 @@ recent_multi_data["deliveries"][3]["events"][0]["date"] = datetime.strftime(yesterday,"%B %-d, %Y %I:%M %p") + " EST" @pytest.mark.asyncio -async def test_recent_shipment_sensor(): +async def test_recent_shipment_sensor(hass): """Test the RecentShipment sensor with data from the recent.json fixture.""" # Mock the coordinator mock_coordinator = AsyncMock(spec=ParcelUpdateCoordinator) @@ -58,9 +58,11 @@ async def test_recent_shipment_sensor(): # Initialize the RecentShipment sensor sensor = RecentShipment(mock_coordinator) + sensor.hass = hass + sensor.async_write_ha_state = Mock() - # Call async_update to fetch data - await sensor.async_update() + # Call _handle_coordinator_update to fetch data + sensor._handle_coordinator_update() # Assert the state and attributes for the first delivery in the fixture assert sensor.state == "Delivery in transit." @@ -87,9 +89,11 @@ async def test_active_shipment_sensor(hass): # Initialize the ActiveShipment sensor sensor = ActiveShipment(mock_coordinator) + sensor.hass = hass + sensor.async_write_ha_state = Mock() # Call async_update to fetch data - await sensor.async_update() + sensor._handle_coordinator_update() # Assert the state and attributes assert sensor.state == "in 1 day" @@ -125,9 +129,11 @@ async def test_collectable_shipment_sensor(hass): # Initialize the ActiveShipment sensor sensor = CollectionShipment(mock_coordinator) + sensor.hass = hass + sensor.async_write_ha_state = Mock() # Call async_update to fetch data - await sensor.async_update() + sensor._handle_coordinator_update() # Assert the state and attributes assert sensor.state == 0 @@ -147,9 +153,11 @@ async def test_recent_shipment_sensor_no_data(hass): # Initialize the RecentShipment sensor sensor = RecentShipment(mock_coordinator) + sensor.hass = hass + sensor.async_write_ha_state = Mock() # Call async_update to fetch data - await sensor.async_update() + sensor._handle_coordinator_update() # Assert the state and attributes assert sensor.state == 'No parcels for now..' @@ -180,9 +188,11 @@ async def test_active_shipment_sensor_no_data(hass): # Initialize the ActiveShipment sensor sensor = ActiveShipment(mock_coordinator) + sensor.hass = hass + sensor.async_write_ha_state = Mock() # Call async_update to fetch data - await sensor.async_update() + sensor._handle_coordinator_update() # Assert the state and attributes assert sensor.state == 'No parcels for now..' @@ -213,9 +223,11 @@ async def test_collection_shipment_sensor_no_data(hass): # Initialize the RecentShipment sensor sensor = CollectionShipment(mock_coordinator) + sensor.hass = hass + sensor.async_write_ha_state = Mock() # Call async_update to fetch data - await sensor.async_update() + sensor._handle_coordinator_update() # Assert the state and attributes assert sensor.state == 0 @@ -225,7 +237,7 @@ async def test_collection_shipment_sensor_no_data(hass): @pytest.mark.asyncio -async def test_recent_shipment_sensor_multi_data(): +async def test_recent_shipment_sensor_multi_data(hass): """Test the RecentShipment sensor with data from the multi.json fixture.""" # Mock the coordinator @@ -236,9 +248,11 @@ async def test_recent_shipment_sensor_multi_data(): # Initialize the RecentShipment sensor sensor = RecentShipment(mock_coordinator) + sensor.hass = hass + sensor.async_write_ha_state = Mock() - # Call async_update to fetch data - await sensor.async_update() + # Call _handle_coordinator_update to fetch data + sensor._handle_coordinator_update() # Assert the state and attributes for the first delivery in the fixture assert sensor.state == "Delivery expecting a pickup by the recipient." @@ -264,9 +278,11 @@ async def test_active_shipment_sensor_multi_data(hass): # Initialize the ActiveShipment sensor sensor = ActiveShipment(mock_coordinator) + sensor.hass = hass + sensor.async_write_ha_state = Mock() # Call async_update to fetch data - await sensor.async_update() + sensor._handle_coordinator_update() # Assert the state and attributes # one parcel arriving today, not an error! @@ -298,9 +314,11 @@ async def test_collectable_shipment_sensor_multi_data(hass): # Initialize the ActiveShipment sensor sensor = CollectionShipment(mock_coordinator) + sensor.hass = hass + sensor.async_write_ha_state = Mock() # Call async_update to fetch data - await sensor.async_update() + sensor._handle_coordinator_update() # Assert the state and attributes assert sensor.state == 1