From c3a6397e8b80ccc77342f3d7beea859ae404ec2f Mon Sep 17 00:00:00 2001 From: Robby Barnes Date: Tue, 20 Jan 2026 18:13:59 -0700 Subject: [PATCH 1/3] feat: Expose delivery window attributes in ActiveShipment sensor Adds three new attributes to the ActiveShipment sensor to expose delivery window information when provided by carriers: - date_expected_end: End of delivery window (for carriers that provide time ranges) - timestamp_expected: Epoch timestamp for expected delivery (more precise, includes timezone) - timestamp_expected_end: End of delivery window as epoch timestamp These attributes are already parsed from the API response and stored in the Shipment class but were not previously exposed to Home Assistant. This enables users to create more precise automations based on delivery windows. Also updates EMPTY_ATTRIBUTES in const.py for consistency. Co-Authored-By: Claude Opus 4.5 --- custom_components/parcelapp/const.py | 3 +++ custom_components/parcelapp/sensor.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/custom_components/parcelapp/const.py b/custom_components/parcelapp/const.py index dfb32f4..1167ce4 100644 --- a/custom_components/parcelapp/const.py +++ b/custom_components/parcelapp/const.py @@ -71,6 +71,9 @@ def __init__( "full_description": "No description", "tracking_number": "None", "date_expected": "None", + "date_expected_end": None, + "timestamp_expected": None, + "timestamp_expected_end": None, "days_until_next_delivery": "No active parcels.", "event": "None", "event_date": "None", diff --git a/custom_components/parcelapp/sensor.py b/custom_components/parcelapp/sensor.py index ea3cb92..4150049 100644 --- a/custom_components/parcelapp/sensor.py +++ b/custom_components/parcelapp/sensor.py @@ -387,6 +387,9 @@ def _handle_coordinator_update(self) -> None: "full_description": description, "tracking_number": tracking_number, "date_expected": date_expected, + "date_expected_end": next_traceable_shipment.date_expected_end, + "timestamp_expected": next_traceable_shipment.timestamp_expected, + "timestamp_expected_end": next_traceable_shipment.timestamp_expected_end, "days_until_next_delivery": days_until_next_delivery, "event": event, "event_date": event_date, From 3bb88da198d3648935767b6711a0ae5a6b27c137 Mon Sep 17 00:00:00 2001 From: Robby Barnes Date: Tue, 20 Jan 2026 18:37:49 -0700 Subject: [PATCH 2/3] Fix tests to include new delivery window attributes Update test assertions to include date_expected_end, timestamp_expected, and timestamp_expected_end attributes that were added to EMPTY_ATTRIBUTES. Co-Authored-By: Claude Opus 4.5 --- tests/test_sensors.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_sensors.py b/tests/test_sensors.py index 00b7ff3..d4d2506 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -103,6 +103,9 @@ async def test_active_shipment_sensor(hass): 'full_description': 'Wireless Mouse Set', 'tracking_number': '8217400125612976', 'date_expected': tomorrow, + 'date_expected_end': None, + 'timestamp_expected': None, + 'timestamp_expected_end': None, 'days_until_next_delivery': 1, 'event': 'Departure Scan', 'event_date': yesterday, @@ -163,6 +166,9 @@ async def test_recent_shipment_sensor_no_data(hass): assert sensor.state == 'No parcels for now..' assert sensor.extra_state_attributes == { 'date_expected': 'None', + 'date_expected_end': None, + 'timestamp_expected': None, + 'timestamp_expected_end': None, 'days_until_next_delivery': 'No active parcels.', 'event': 'None', 'event_date': 'None', @@ -198,6 +204,9 @@ async def test_active_shipment_sensor_no_data(hass): assert sensor.state == 'No parcels for now..' assert sensor.extra_state_attributes == { 'date_expected': 'None', + 'date_expected_end': None, + 'timestamp_expected': None, + 'timestamp_expected_end': None, 'days_until_next_delivery': 'No active parcels.', 'event': 'None', 'event_date': 'None', @@ -289,6 +298,9 @@ async def test_active_shipment_sensor_multi_data(hass): assert sensor.state == '1 parcel' assert sensor.extra_state_attributes == { 'date_expected': today, + 'date_expected_end': None, + 'timestamp_expected': None, + 'timestamp_expected_end': None, 'days_until_next_delivery': 0, 'event': 'Postmark Mailpiece by Carrier', 'event_date': yesterday, From 5026f84feaaf2abf9e465473333eabcbab84a673 Mon Sep 17 00:00:00 2001 From: Robby Barnes Date: Sat, 24 Jan 2026 11:49:26 -0700 Subject: [PATCH 3/3] feat: Add delivery window attributes to RecentShipment sensor Per maintainer feedback: - Added date_expected_end, timestamp_expected, timestamp_expected_end attributes to RecentShipment sensor (matching ActiveShipment) - Updated recent.json fixture with actual delivery window values - Updated tests to verify non-None delivery window values - Added dedicated test_delivery_window_attributes test - Fixed TypeError handling for timestamp parsing (caught by CodeRabbit) Co-Authored-By: Claude Opus 4.5 --- custom_components/parcelapp/sensor.py | 18 +++++ tests/fixtures/recent.json | 3 + tests/test_sensors.py | 97 ++++++++++++++++++++++++--- 3 files changed, 108 insertions(+), 10 deletions(-) diff --git a/custom_components/parcelapp/sensor.py b/custom_components/parcelapp/sensor.py index 4150049..2b21f60 100644 --- a/custom_components/parcelapp/sensor.py +++ b/custom_components/parcelapp/sensor.py @@ -103,6 +103,21 @@ def _handle_coordinator_update(self) -> None: date_expected = dateparse(date_expected_raw) except KeyError: date_expected = "Unknown" + try: + date_expected_end_raw = data[0]["date_expected_end"] + date_expected_end = dateparse(date_expected_end_raw) + except KeyError: + date_expected_end = None + try: + timestamp_expected_raw = data[0]["timestamp_expected"] + timestamp_expected = datetime.fromtimestamp(timestamp_expected_raw) + except (KeyError, TypeError): + timestamp_expected = None + try: + timestamp_expected_end_raw = data[0]["timestamp_expected_end"] + timestamp_expected_end = datetime.fromtimestamp(timestamp_expected_end_raw) + except (KeyError, TypeError): + timestamp_expected_end = None try: event_date_raw = data[0]["events"][0]["date"] event_date = dateparse(event_date_raw) @@ -126,6 +141,9 @@ def _handle_coordinator_update(self) -> None: "full_description": description, "tracking_number": tracking_number, "date_expected": date_expected, + "date_expected_end": date_expected_end, + "timestamp_expected": timestamp_expected, + "timestamp_expected_end": timestamp_expected_end, "event_date": event_date, "event_location": event_location, "status": status, diff --git a/tests/fixtures/recent.json b/tests/fixtures/recent.json index 1c0469c..ee3d61a 100644 --- a/tests/fixtures/recent.json +++ b/tests/fixtures/recent.json @@ -8,6 +8,9 @@ "tracking_number": "8217400125612976", "extra_information": "FedEx SmartPost", "date_expected": "2023-03-05T00:00:00Z", + "date_expected_end": "2023-03-05T17:00:00Z", + "timestamp_expected": 1677974400, + "timestamp_expected_end": 1678035600, "events": [ { "event": "Departure Scan", diff --git a/tests/test_sensors.py b/tests/test_sensors.py index d4d2506..cdccad2 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -25,6 +25,12 @@ recent_data["utc_timestamp"] = mock_datetime # Modify the active parcel to be out for delivery tomorrow recent_data["deliveries"][0]["date_expected"] = datetime.strftime(tomorrow,"%Y-%m-%d") + "T00:00:00Z" +# Set delivery window end to tomorrow as well +recent_data["deliveries"][0]["date_expected_end"] = datetime.strftime(tomorrow,"%Y-%m-%d") + "T17:00:00Z" +# Set timestamp values for delivery window +tomorrow_start = datetime.combine(tomorrow, datetime.min.time()) +recent_data["deliveries"][0]["timestamp_expected"] = int(tomorrow_start.timestamp()) +recent_data["deliveries"][0]["timestamp_expected_end"] = int(tomorrow_start.timestamp()) + 61200 # +17 hours # Modify the active parcel's event date to be yesterday recent_data["deliveries"][0]["events"][0]["date"] = datetime.strftime(yesterday,"%A, %B %-d, %Y %-I:%M %p") @@ -66,15 +72,17 @@ async def test_recent_shipment_sensor(hass): # Assert the state and attributes for the first delivery in the fixture assert sensor.state == "Delivery in transit." - assert sensor.extra_state_attributes == { - "full_description": "Wireless Mouse Set", - "tracking_number": "8217400125612976", - "date_expected": tomorrow, - "event_date": yesterday, - "event_location": "Harrisburg, PA, USA", - "status": "Delivery in transit.", - "carrier": "Fedex", - } + attrs = sensor.extra_state_attributes + assert attrs["full_description"] == "Wireless Mouse Set" + assert attrs["tracking_number"] == "8217400125612976" + assert attrs["date_expected"] == tomorrow + assert attrs["date_expected_end"] == tomorrow + assert attrs["timestamp_expected"] == datetime.fromtimestamp(recent_data["deliveries"][0]["timestamp_expected"]) + assert attrs["timestamp_expected_end"] == datetime.fromtimestamp(recent_data["deliveries"][0]["timestamp_expected_end"]) + assert attrs["event_date"] == yesterday + assert attrs["event_location"] == "Harrisburg, PA, USA" + assert attrs["status"] == "Delivery in transit." + assert attrs["carrier"] == "Fedex" @pytest.mark.asyncio @@ -269,6 +277,9 @@ async def test_recent_shipment_sensor_multi_data(hass): "full_description": "Collectable Parcel", "tracking_number": "12345678", "date_expected": "Unknown", + "date_expected_end": None, + "timestamp_expected": None, + "timestamp_expected_end": None, "event_date": yesterday, "event_location": "Somewhere", "status": "Delivery expecting a pickup by the recipient.", @@ -343,4 +354,70 @@ async def test_collectable_shipment_sensor_multi_data(hass): "delivered": datetime.strftime(yesterday,"%d.%m.%Y %H:%M") } ], - } \ No newline at end of file + } + + +@pytest.mark.asyncio +async def test_delivery_window_attributes(hass): + """Test that delivery window attributes are properly exposed on both sensors.""" + # Create test data with delivery window fields + tomorrow_ts = int(datetime.combine(tomorrow, datetime.min.time()).timestamp()) + tomorrow_end_ts = tomorrow_ts + 14400 # +4 hours + + delivery_window_data = { + "success": True, + "deliveries": [ + { + "carrier_code": "fedex", + "description": "Package with delivery window", + "status_code": 4, # Out for delivery + "tracking_number": "1234567890", + "date_expected": datetime.strftime(tomorrow, "%Y-%m-%d") + "T09:00:00Z", + "date_expected_end": datetime.strftime(tomorrow, "%Y-%m-%d") + "T13:00:00Z", + "timestamp_expected": tomorrow_ts, + "timestamp_expected_end": tomorrow_end_ts, + "events": [ + { + "event": "Out for Delivery", + "date": datetime.strftime(today, "%A, %B %-d, %Y %-I:%M %p"), + "location": "Local Facility" + } + ] + } + ], + "carrier_codes": {"fedex": "FedEx"}, + "carrier_codes_updated": mock_datetime, + "utc_timestamp": mock_datetime, + } + + # Mock the coordinator + mock_coordinator = AsyncMock(spec=ParcelUpdateCoordinator) + mock_coordinator.config_entry = AsyncMock(spec=ParcelUpdateCoordinator) + mock_coordinator.data = delivery_window_data + mock_coordinator.config_entry.entry_id = "test_entry_12345" + + # Test RecentShipment sensor + recent_sensor = RecentShipment(mock_coordinator) + recent_sensor.hass = hass + recent_sensor.async_write_ha_state = Mock() + recent_sensor._handle_coordinator_update() + + # Verify delivery window attributes on RecentShipment + attrs = recent_sensor.extra_state_attributes + assert attrs["date_expected"] == tomorrow + assert attrs["date_expected_end"] == tomorrow + assert attrs["timestamp_expected"] == datetime.fromtimestamp(tomorrow_ts) + assert attrs["timestamp_expected_end"] == datetime.fromtimestamp(tomorrow_end_ts) + + # Test ActiveShipment sensor + active_sensor = ActiveShipment(mock_coordinator) + active_sensor.hass = hass + active_sensor.async_write_ha_state = Mock() + active_sensor._handle_coordinator_update() + + # Verify delivery window attributes on ActiveShipment + attrs = active_sensor.extra_state_attributes + assert attrs["date_expected"] == tomorrow + assert attrs["date_expected_end"] == tomorrow + assert attrs["timestamp_expected"] == datetime.fromtimestamp(tomorrow_ts) + assert attrs["timestamp_expected_end"] == datetime.fromtimestamp(tomorrow_end_ts) \ No newline at end of file