From 8632b901ca2a7f0ded7c6dc8f83dd01919f0acc8 Mon Sep 17 00:00:00 2001 From: Robby Barnes Date: Tue, 20 Jan 2026 18:08:19 -0700 Subject: [PATCH 01/10] feat: Add language parameter to add_parcel service Adds optional language parameter to the add_parcel service, allowing users to specify the language for delivery information (ISO 639-1 two-letter code). This addresses issue #70 and aligns with the Parcel App API which supports an optional 'language' parameter for the add-delivery endpoint. Changes: - Added LANGUAGE and DEFAULT_LANGUAGE constants to const.py - Updated ADD_PARCEL_SCHEMA to include optional language field - Modified async_add_parcel to include language in API payload - Added language field to services.yaml - Added translation strings for language field Co-Authored-By: Claude Opus 4.5 --- custom_components/parcelapp/const.py | 4 +++- custom_components/parcelapp/services.py | 5 +++++ custom_components/parcelapp/services.yaml | 8 ++++++++ custom_components/parcelapp/translations/en.json | 4 ++++ 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/custom_components/parcelapp/const.py b/custom_components/parcelapp/const.py index dfb32f4..19a11f5 100644 --- a/custom_components/parcelapp/const.py +++ b/custom_components/parcelapp/const.py @@ -83,4 +83,6 @@ def __init__( COURIER = "courier" TYPE = "type" OLD_NUMBER = "oldNumber" -OLD_TYPE = "oldType" \ No newline at end of file +OLD_TYPE = "oldType" +LANGUAGE = "language" +DEFAULT_LANGUAGE = "en" diff --git a/custom_components/parcelapp/services.py b/custom_components/parcelapp/services.py index 8dd3c5e..b451520 100644 --- a/custom_components/parcelapp/services.py +++ b/custom_components/parcelapp/services.py @@ -13,7 +13,9 @@ from .const import ( COURIER, + DEFAULT_LANGUAGE, DOMAIN, + LANGUAGE, OLD_NUMBER, OLD_TYPE, PARCEL_NAME, @@ -30,6 +32,7 @@ vol.Required(TRACKING_NUMBER): cv.string, vol.Required(COURIER): cv.string, vol.Required("send_push_confirmation", default=False): cv.boolean, + vol.Optional(LANGUAGE, default=DEFAULT_LANGUAGE): cv.string, } ) @@ -184,6 +187,7 @@ async def async_add_parcel(call: ServiceCall): tracking_number = str(call.data[TRACKING_NUMBER]) courier = call.data[COURIER] send_push = call.data.get("send_push_confirmation", False) + language = call.data.get(LANGUAGE, DEFAULT_LANGUAGE) # Prepare the payload for the official API payload = { @@ -191,6 +195,7 @@ async def async_add_parcel(call: ServiceCall): "carrier_code": courier, "description": parcel_name, "send_push_confirmation": send_push, + "language": language, } headers = { "api-key": api_key, diff --git a/custom_components/parcelapp/services.yaml b/custom_components/parcelapp/services.yaml index f81d253..3a29f18 100644 --- a/custom_components/parcelapp/services.yaml +++ b/custom_components/parcelapp/services.yaml @@ -35,6 +35,14 @@ add_parcel: default: false selector: boolean: + language: + name: "Language" + description: "Language for delivery information (ISO 639-1 two-letter code). Availability depends on carrier." + example: "en" + required: false + default: "en" + selector: + text: delete_parcel: fields: diff --git a/custom_components/parcelapp/translations/en.json b/custom_components/parcelapp/translations/en.json index 935135c..fa915dc 100644 --- a/custom_components/parcelapp/translations/en.json +++ b/custom_components/parcelapp/translations/en.json @@ -43,6 +43,10 @@ "courier": { "name": "Courier", "description": "The courier service handling the parcel. Refer to the list of supported carrier keys, for example, for ACS Courier the key is 'acs'. The list is available at https://api.parcel.app/external/supported_carriers.json." + }, + "language": { + "name": "Language", + "description": "Language for delivery information (ISO 639-1 two-letter code, e.g., 'en', 'de', 'fr'). Availability depends on carrier. Defaults to English." } } }, From c3a6397e8b80ccc77342f3d7beea859ae404ec2f Mon Sep 17 00:00:00 2001 From: Robby Barnes Date: Tue, 20 Jan 2026 18:13:59 -0700 Subject: [PATCH 02/10] 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 03/10] 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 6a9f49e69f4fff7bdce7f04ce657490f13bb86ed Mon Sep 17 00:00:00 2001 From: Robby Barnes Date: Sat, 24 Jan 2026 11:49:03 -0700 Subject: [PATCH 04/10] fix: Reorder services.yaml fields per maintainer feedback Move language field before send_push_confirmation for better UX as requested in PR review. Co-Authored-By: Claude Opus 4.5 --- custom_components/parcelapp/services.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/custom_components/parcelapp/services.yaml b/custom_components/parcelapp/services.yaml index 3a29f18..6eca84c 100644 --- a/custom_components/parcelapp/services.yaml +++ b/custom_components/parcelapp/services.yaml @@ -28,13 +28,6 @@ add_parcel: required: true selector: text: - send_push_confirmation: - name: "Send Push Notification" - description: "Send a push notification when the parcel is added" - required: false - default: false - selector: - boolean: language: name: "Language" description: "Language for delivery information (ISO 639-1 two-letter code). Availability depends on carrier." @@ -43,6 +36,13 @@ add_parcel: default: "en" selector: text: + send_push_confirmation: + name: "Send Push Notification" + description: "Send a push notification when the parcel is added" + required: false + default: false + selector: + boolean: delete_parcel: fields: From 5026f84feaaf2abf9e465473333eabcbab84a673 Mon Sep 17 00:00:00 2001 From: Robby Barnes Date: Sat, 24 Jan 2026 11:49:26 -0700 Subject: [PATCH 05/10] 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 From 444766282ce9d490701e55a74ef05d0d2d82208e Mon Sep 17 00:00:00 2001 From: Julian De Vita Date: Sat, 4 Apr 2026 19:03:00 -0400 Subject: [PATCH 06/10] feat: add carrier auto-detection from tracking numbers using jkeen/tracking_number_data patterns --- .gitignore | 5 +- .../parcelapp/carrier_detection.py | 385 +++++ custom_components/parcelapp/services.py | 115 +- custom_components/parcelapp/services.yaml | 18 +- .../parcelapp/tracking_data/amazon.json | 45 + .../parcelapp/tracking_data/canadapost.json | 31 + .../parcelapp/tracking_data/dhl.json | 168 ++ .../parcelapp/tracking_data/dpd.json | 341 ++++ .../parcelapp/tracking_data/fedex.json | 303 ++++ .../parcelapp/tracking_data/landmark.json | 26 + .../parcelapp/tracking_data/lasership.json | 84 + .../parcelapp/tracking_data/old_dominion.json | 63 + .../parcelapp/tracking_data/ontrac.json | 70 + .../parcelapp/tracking_data/s10.json | 1435 +++++++++++++++++ .../parcelapp/tracking_data/ups.json | 193 +++ .../parcelapp/tracking_data/usps.json | 271 ++++ .../parcelapp/translations/en.json | 14 +- tests/test_carrier_detection.py | 256 +++ 18 files changed, 3810 insertions(+), 13 deletions(-) create mode 100644 custom_components/parcelapp/carrier_detection.py create mode 100644 custom_components/parcelapp/tracking_data/amazon.json create mode 100644 custom_components/parcelapp/tracking_data/canadapost.json create mode 100644 custom_components/parcelapp/tracking_data/dhl.json create mode 100644 custom_components/parcelapp/tracking_data/dpd.json create mode 100644 custom_components/parcelapp/tracking_data/fedex.json create mode 100644 custom_components/parcelapp/tracking_data/landmark.json create mode 100644 custom_components/parcelapp/tracking_data/lasership.json create mode 100644 custom_components/parcelapp/tracking_data/old_dominion.json create mode 100644 custom_components/parcelapp/tracking_data/ontrac.json create mode 100644 custom_components/parcelapp/tracking_data/s10.json create mode 100644 custom_components/parcelapp/tracking_data/ups.json create mode 100644 custom_components/parcelapp/tracking_data/usps.json create mode 100644 tests/test_carrier_detection.py diff --git a/.gitignore b/.gitignore index 90466c8..90c73f4 100644 --- a/.gitignore +++ b/.gitignore @@ -173,4 +173,7 @@ cython_debug/ .history/ # Built Visual Studio Code Extensions -*.vsix \ No newline at end of file +*.vsix + +# Internal research/analysis docs +docs/research/ \ No newline at end of file diff --git a/custom_components/parcelapp/carrier_detection.py b/custom_components/parcelapp/carrier_detection.py new file mode 100644 index 0000000..a5e88d2 --- /dev/null +++ b/custom_components/parcelapp/carrier_detection.py @@ -0,0 +1,385 @@ +"""Carrier auto-detection from tracking numbers. + +Uses pattern data from jkeen/tracking_number_data to identify carriers +by matching tracking number formats and validating checksums. +""" + +from __future__ import annotations + +import json +import logging +import re +from dataclasses import dataclass +from pathlib import Path + +_LOGGER = logging.getLogger(__name__) + +TRACKING_DATA_DIR = Path(__file__).parent / "tracking_data" + +# Maps jkeen courier_code -> Parcel app carrier code. +# s10 is excluded because it's a universal postal format covering 160+ countries. +JKEEN_TO_PARCEL_APP_MAP: dict[str, str] = { + "ups": "ups", + "fedex": "fedex", + "usps": "usps", + "dhl": "dhl", + "ontrac": "ontrac", + "lasership": "lasership", + "canada_post": "cp", + "dpd": "dpd", + "amazon": "amzlus", + "landmark": "landmark", + "old_dominion": "odfl", +} + +# Courier files to skip for auto-detection +EXCLUDED_COURIERS = {"s10"} + + +@dataclass +class CarrierMatch: + """Result of a carrier detection attempt.""" + + courier_code: str + parcel_app_code: str | None + carrier_name: str + format_name: str + checksum_valid: bool | None + confidence: float + + +@dataclass +class _TrackingPattern: + """Compiled tracking number pattern with validation rules.""" + + courier_code: str + carrier_name: str + format_name: str + regex: re.Pattern[str] + checksum: dict | None + serial_number_format: dict | None + + +def _char_to_digit(c: str) -> int: + """Convert a character to a digit for checksum calculation. + + Digits return their face value. Letters use (ord - 3) % 10, + matching the jkeen/tracking_number Ruby gem convention. + """ + if c.isdigit(): + return int(c) + return (ord(c.upper()) - 3) % 10 + + +def _serial_to_digits(serial: str) -> list[int]: + """Convert an alphanumeric serial string to a list of digits.""" + return [_char_to_digit(c) for c in serial if c.isalnum()] + + +def _checksum_mod10( + serial_chars: str, + check_digit: int, + evens_multiplier: int = 1, + odds_multiplier: int = 2, + reverse: bool = False, +) -> bool: + """Validate mod10 checksum. + + Note: this does NOT split multi-digit products (not Luhn-style). + Raw products are summed directly. + """ + digits = _serial_to_digits(serial_chars) + if reverse: + digits = list(reversed(digits)) + total = 0 + for i, d in enumerate(digits): + x = d + if i % 2 == 0: + x *= evens_multiplier + else: + x *= odds_multiplier + total += x + remainder = total % 10 + expected = (10 - remainder) if remainder != 0 else 0 + return expected == check_digit + + +def _checksum_mod7(serial_chars: str, check_digit: int) -> bool: + """Validate mod7 checksum.""" + digits_only = "".join(c for c in serial_chars if c.isdigit()) + serial_num = int(digits_only) + return serial_num % 7 == check_digit + + +def _checksum_s10(serial_digits: list[int], check_digit: int) -> bool: + """Validate S10 international postal checksum.""" + weights = [8, 6, 4, 2, 3, 5, 9, 7] + if len(serial_digits) != 8: + return False + total = sum(d * w for d, w in zip(serial_digits, weights)) + remainder = total % 11 + if remainder == 0: + expected = 5 + elif remainder == 1: + expected = 0 + else: + expected = 11 - remainder + return expected == check_digit + + +def _checksum_sum_product( + serial_digits: list[int], + check_digit: int, + weightings: list[int], + modulo1: int, + modulo2: int, +) -> bool: + """Validate weighted sum-product checksum with dual modulo.""" + total = sum(d * w for d, w in zip(serial_digits, weightings)) + expected = (total % modulo1) % modulo2 + return expected == check_digit + + +def _checksum_luhn(serial_chars: str, check_digit: int) -> bool: + """Validate Luhn checksum. + + Unlike mod10, Luhn DOES subtract 9 from doubled values > 9. + Processes the serial in reverse, doubling even-indexed positions. + """ + digits = [int(c) for c in serial_chars if c.isdigit()] + total = 0 + for i, d in enumerate(reversed(digits)): + x = d + if i % 2 == 0: + x *= 2 + if x > 9: + x -= 9 + total += x + remainder = total % 10 + expected = (10 - remainder) if remainder != 0 else 0 + return expected == check_digit + + +def _checksum_mod_37_36(serial_chars: str, check_char: str) -> bool: + """Validate ISO 7064 mod 37/36 checksum (alphanumeric). + + Based on DPD Parcel Label Specification, matching jkeen Ruby implementation. + """ + weights = {chr(i + ord("A")): i + 10 for i in range(26)} # A=10..Z=35 + mod = 36 + + cd = mod + for ch in serial_chars: + if ch.isalpha(): + val = weights[ch.upper()] + else: + val = int(ch) + + cd = val + cd + if cd > mod: + cd -= mod + cd = cd * 2 + if cd > mod + 1: + cd -= mod + 1 + + cd = (mod + 1) - cd + if cd == mod: + cd = 0 + + # Convert computed check to character + if cd >= 10: + computed = chr(ord("A") + cd - 10) + else: + computed = str(cd) + + return computed == check_char.upper() + + +def _extract_digits(text: str) -> list[int]: + """Extract only digit characters as a list of ints.""" + return [int(c) for c in text if c.isdigit()] + + +def _compile_regex(regex_input: str | list[str]) -> re.Pattern[str]: + """Compile a jkeen regex pattern to a Python regex. + + Handles array-of-strings format and converts PCRE named groups to Python syntax. + """ + raw = "".join(regex_input) if isinstance(regex_input, list) else regex_input + # Convert (?...) to (?P...) + converted = re.sub(r"\(\?<([^>]+)>", r"(?P<\1>", raw) + return re.compile(f"^{converted}$", re.IGNORECASE) + + +def _validate_checksum( + match: re.Match[str], + checksum_config: dict, + serial_number_format: dict | None, +) -> bool: + """Validate a tracking number's checksum given the match and config.""" + algo = checksum_config.get("name", "") + + serial_group = match.group("SerialNumber") + check_group = match.group("CheckDigit") + if not serial_group or not check_group: + return False + + serial_clean = serial_group.replace(" ", "") + check_clean = check_group.strip() + + # Handle serial_number_format.prepend_if + if serial_number_format and "prepend_if" in serial_number_format: + prepend = serial_number_format["prepend_if"] + prepend_regex = prepend.get("matches_regex", "") + prepend_content = prepend.get("content", "") + if prepend_regex and re.match(prepend_regex, serial_clean): + serial_clean = prepend_content + serial_clean + + if algo == "mod10": + check_digit = int(check_clean[0]) + return _checksum_mod10( + serial_clean, + check_digit, + evens_multiplier=checksum_config.get("evens_multiplier", 1), + odds_multiplier=checksum_config.get("odds_multiplier", 2), + reverse=checksum_config.get("reverse", False), + ) + + if algo == "mod7": + check_digit = int(check_clean[0]) + return _checksum_mod7(serial_clean, check_digit) + + if algo == "s10": + serial_digits = _extract_digits(serial_clean) + check_digit = int(check_clean[0]) + return _checksum_s10(serial_digits, check_digit) + + if algo == "sum_product_with_weightings_and_modulo": + serial_digits = _extract_digits(serial_clean) + check_digit = int(check_clean[0]) + return _checksum_sum_product( + serial_digits, + check_digit, + weightings=checksum_config.get("weightings", []), + modulo1=checksum_config.get("modulo1", 10), + modulo2=checksum_config.get("modulo2", 10), + ) + + if algo == "luhn": + check_digit = int(check_clean[0]) + return _checksum_luhn(serial_clean, check_digit) + + if algo == "mod_37_36": + # Serial includes digits and the check char is alphanumeric + serial_alnum = re.sub(r"\s", "", serial_clean) + return _checksum_mod_37_36(serial_alnum, check_clean[0]) + + _LOGGER.debug("Unknown checksum algorithm: %s", algo) + return False + + +class CarrierDetector: + """Detects carrier from tracking number using jkeen/tracking_number_data patterns.""" + + def __init__(self) -> None: + self._patterns: list[_TrackingPattern] = [] + self._loaded = False + + def load(self) -> None: + """Load all tracking data JSON files from the bundled data directory.""" + if self._loaded: + return + + for json_file in sorted(TRACKING_DATA_DIR.glob("*.json")): + try: + data = json.loads(json_file.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as err: + _LOGGER.warning("Failed to load tracking data from %s: %s", json_file.name, err) + continue + + courier_code = data.get("courier_code", "") + if courier_code in EXCLUDED_COURIERS: + continue + + carrier_name = data.get("name", courier_code) + + for tn in data.get("tracking_numbers", []): + regex_input = tn.get("regex") + if not regex_input: + continue + + try: + compiled = _compile_regex(regex_input) + except re.error as err: + _LOGGER.warning( + "Failed to compile regex for %s/%s: %s", + courier_code, + tn.get("name", "unknown"), + err, + ) + continue + + validation = tn.get("validation", {}) + checksum = validation.get("checksum") + serial_number_format = validation.get("serial_number_format") + + self._patterns.append( + _TrackingPattern( + courier_code=courier_code, + carrier_name=carrier_name, + format_name=tn.get("name", "Unknown"), + regex=compiled, + checksum=checksum, + serial_number_format=serial_number_format, + ) + ) + + self._loaded = True + _LOGGER.debug("Loaded %d tracking number patterns", len(self._patterns)) + + def detect(self, tracking_number: str) -> list[CarrierMatch]: + """Detect carrier(s) for a tracking number. + + Returns matches sorted by confidence (highest first). + """ + if not self._loaded: + self.load() + + results: list[CarrierMatch] = [] + + for pattern in self._patterns: + match = pattern.regex.match(tracking_number) + if not match: + continue + + checksum_valid: bool | None = None + confidence = 0.5 # regex match only + + if pattern.checksum: + try: + checksum_valid = _validate_checksum( + match, pattern.checksum, pattern.serial_number_format + ) + confidence = 1.0 if checksum_valid else 0.3 + except (ValueError, IndexError, KeyError) as err: + _LOGGER.debug( + "Checksum validation error for %s/%s: %s", + pattern.courier_code, + pattern.format_name, + err, + ) + confidence = 0.4 + + results.append( + CarrierMatch( + courier_code=pattern.courier_code, + parcel_app_code=JKEEN_TO_PARCEL_APP_MAP.get(pattern.courier_code), + carrier_name=pattern.carrier_name, + format_name=pattern.format_name, + checksum_valid=checksum_valid, + confidence=confidence, + ) + ) + + results.sort(key=lambda m: m.confidence, reverse=True) + return results diff --git a/custom_components/parcelapp/services.py b/custom_components/parcelapp/services.py index 45223e3..b9182c4 100644 --- a/custom_components/parcelapp/services.py +++ b/custom_components/parcelapp/services.py @@ -11,6 +11,7 @@ from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .carrier_detection import CarrierDetector from .const import ( CARRIER_CODE_ENDPOINT, COURIER, @@ -29,7 +30,7 @@ vol.Required("device_id"): cv.string, vol.Required(PARCEL_NAME): cv.string, vol.Required(TRACKING_NUMBER): cv.string, - vol.Required(COURIER): cv.string, + vol.Optional(COURIER): cv.string, vol.Required("send_push_confirmation", default=False): cv.boolean, } ) @@ -47,12 +48,18 @@ vol.Required("device_id"): cv.string, vol.Required(PARCEL_NAME): cv.string, vol.Required(TRACKING_NUMBER): cv.string, - vol.Required(COURIER): cv.string, + vol.Optional(COURIER): cv.string, vol.Required(OLD_NUMBER): cv.string, vol.Required(OLD_TYPE): cv.string, } ) +DETECT_CARRIER_SCHEMA = vol.Schema( + { + vol.Required(TRACKING_NUMBER): cv.string, + } +) + async def async_get_config_entry_from_device_id(hass: HomeAssistant, device_id: str): """Get the config entry from a device ID.""" @@ -161,10 +168,69 @@ def get_http_error_message( ) +def _resolve_courier( + detector: CarrierDetector, tracking_number: str, courier: str | None +) -> tuple[str, dict | None]: + """Resolve the courier code, auto-detecting if not provided. + + Returns (courier_code, detection_info_dict_or_None). + Raises HomeAssistantError if auto-detection fails. + """ + if courier: + return courier, None + + matches = detector.detect(tracking_number) + if not matches: + raise HomeAssistantError( + f"Could not auto-detect carrier for tracking number '{tracking_number}'. " + f"Please provide the 'courier' field manually. " + f"See {CARRIER_CODE_ENDPOINT} for supported carrier codes." + ) + + # Filter to matches that have a Parcel app mapping + mapped = [m for m in matches if m.parcel_app_code is not None] + if not mapped: + names = ", ".join(m.carrier_name for m in matches) + raise HomeAssistantError( + f"Detected carrier(s) [{names}] but none map to a supported Parcel app code. " + f"Please provide the 'courier' field manually." + ) + + high_confidence = [m for m in mapped if m.confidence >= 0.8] + + if len(high_confidence) == 1: + best = high_confidence[0] + elif len(high_confidence) > 1: + options = ", ".join( + f"{m.parcel_app_code} ({m.carrier_name})" for m in high_confidence + ) + raise HomeAssistantError( + f"Multiple carriers match tracking number '{tracking_number}': {options}. " + f"Please provide the 'courier' field to disambiguate." + ) + else: + best = mapped[0] + _LOGGER.warning( + "Low confidence carrier detection for %s: %s (%.0f%%)", + tracking_number, + best.parcel_app_code, + best.confidence * 100, + ) + + detection_info = { + "carrier_auto_detected": True, + "detected_carrier_name": best.carrier_name, + "detected_format": best.format_name, + } + return best.parcel_app_code, detection_info + + async def async_register_services(hass: HomeAssistant): """Register ParcelApp services.""" session = async_get_clientsession(hass) + detector = CarrierDetector() + detector.load() async def async_add_parcel(call: ServiceCall): """Add a parcel to ParcelApp using the official API.""" @@ -183,7 +249,9 @@ async def async_add_parcel(call: ServiceCall): parcel_name = call.data[PARCEL_NAME] tracking_number = str(call.data[TRACKING_NUMBER]) - courier = call.data[COURIER] + courier, detection_info = _resolve_courier( + detector, tracking_number, call.data.get(COURIER) + ) send_push = call.data.get("send_push_confirmation", False) # Prepare the payload for the official API @@ -224,12 +292,15 @@ async def async_add_parcel(call: ServiceCall): notification_id=f"parcelapp_add_{tracking_number}", ) - return { + result = { "success": True, "parcel_name": parcel_name, "tracking_number": tracking_number, "carrier": courier, } + if detection_info: + result.update(detection_info) + return result else: error_msg = result.get("error_message", "Unknown error") _LOGGER.error( @@ -382,7 +453,9 @@ async def async_edit_parcel(call: ServiceCall): """Edit a parcel in ParcelApp (BETA).""" parcel_name = call.data[PARCEL_NAME] tracking_number = str(call.data[TRACKING_NUMBER]) - courier = call.data[COURIER] + courier, detection_info = _resolve_courier( + detector, tracking_number, call.data.get(COURIER) + ) old_number = str(call.data[OLD_NUMBER]) old_type = call.data[OLD_TYPE] @@ -453,7 +526,7 @@ async def async_edit_parcel(call: ServiceCall): notification_id=f"parcelapp_edit_{tracking_number}", ) - return { + result = { "success": True, "parcel_name": parcel_name, "tracking_number": tracking_number, @@ -461,6 +534,9 @@ async def async_edit_parcel(call: ServiceCall): "old_tracking_number": old_number, "old_carrier": old_type, } + if detection_info: + result.update(detection_info) + return result except HomeAssistantError: raise @@ -490,6 +566,25 @@ async def async_edit_parcel(call: ServiceCall): ) raise HomeAssistantError(f"Unexpected error: {err}") from err + async def async_detect_carrier(call: ServiceCall): + """Detect carrier from a tracking number.""" + tracking_number = str(call.data[TRACKING_NUMBER]) + matches = detector.detect(tracking_number) + return { + "tracking_number": tracking_number, + "matches": [ + { + "carrier_code": m.parcel_app_code or m.courier_code, + "carrier_name": m.carrier_name, + "format": m.format_name, + "confidence": m.confidence, + "checksum_valid": m.checksum_valid, + } + for m in matches + ], + "best_match": matches[0].parcel_app_code if matches else None, + } + description_placeholders = { "supported_carriers_url": CARRIER_CODE_ENDPOINT, } @@ -520,3 +615,11 @@ async def async_edit_parcel(call: ServiceCall): supports_response=SupportsResponse.OPTIONAL, description_placeholders=description_placeholders, ) + + hass.services.async_register( + DOMAIN, + "detect_carrier", + async_detect_carrier, + schema=DETECT_CARRIER_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/custom_components/parcelapp/services.yaml b/custom_components/parcelapp/services.yaml index f81d253..27ee0ea 100644 --- a/custom_components/parcelapp/services.yaml +++ b/custom_components/parcelapp/services.yaml @@ -23,9 +23,9 @@ add_parcel: text: courier: name: "Courier" - description: "Courier/carrier code (e.g., ups, fedex, usps)" + description: "Courier/carrier code (e.g., ups, fedex, usps). If omitted, the carrier will be auto-detected from the tracking number." example: "ups" - required: true + required: false selector: text: send_push_confirmation: @@ -81,9 +81,9 @@ edit_parcel: text: courier: name: "Courier" - description: "New courier/carrier code" + description: "New courier/carrier code. If omitted, the carrier will be auto-detected from the tracking number." example: "fedex" - required: true + required: false selector: text: oldNumber: @@ -98,5 +98,15 @@ edit_parcel: description: "Current courier/carrier code of the parcel to edit" example: "fedex" required: true + selector: + text: + +detect_carrier: + fields: + tracking_number: + name: "Tracking Number" + description: "The tracking number to identify the carrier for" + example: "1Z5R89390357567127" + required: true selector: text: \ No newline at end of file diff --git a/custom_components/parcelapp/tracking_data/amazon.json b/custom_components/parcelapp/tracking_data/amazon.json new file mode 100644 index 0000000..a3ff44f --- /dev/null +++ b/custom_components/parcelapp/tracking_data/amazon.json @@ -0,0 +1,45 @@ +{ + "name": "Amazon", + "courier_code": "amazon", + "tracking_numbers": [ + { + "name": "Amazon Logistics", + "id": "amazon_logistics", + "regex": [ + "\\s*T\\s*B\\s*[ACM]\\s*(?([0-9]\\s*){12,12})\\s*" + ], + "validation": {}, + "test_numbers": { + "valid": [ + "TBA000000000000", + "TBA010000000000", + "TBC 000000000000", + "TBM502887274000" + ], + "invalid": [ + "TBA50288727400A", + "000000000000000", + "000000000000", + "TBB000000000000" + ] + } + }, + { + "name": "Amazon International", + "id": "amazon_international", + "regex": [ + "\\s*[AFC]\\s*(?([0-9]\\s*){10,10})\\s*" + ], + "validation": {}, + "test_numbers": { + "valid": [ + "C1004444443", + "C1004444444" + ], + "invalid": [ + "D1234567890" + ] + } + } + ] +} diff --git a/custom_components/parcelapp/tracking_data/canadapost.json b/custom_components/parcelapp/tracking_data/canadapost.json new file mode 100644 index 0000000..c6b7bee --- /dev/null +++ b/custom_components/parcelapp/tracking_data/canadapost.json @@ -0,0 +1,31 @@ +{ + "name": "Canada Post", + "courier_code": "canada_post", + "tracking_numbers": [ + { + "name": "Canada Post (16)", + "id": "canada_post", + "regex": "\\s*(?(?([0-9]\\s*){7})([0-9]\\s*){8})(?[0-9]\\s*)", + "validation": { + "checksum": { + "name": "mod10", + "evens_multiplier": 3, + "odds_multiplier": 1 + } + }, + "tracking_url": "https://www.canadapost-postescanada.ca/track-reperage/en#/search?searchFor=%s", + "test_numbers": { + "valid": [ + "0073938000549297", + "7035114477138472", + "4002847016405018" + ], + "invalid": [ + "0073938000549292", + "7035114477138471", + "5002847016405018" + ] + } + } + ] +} diff --git a/custom_components/parcelapp/tracking_data/dhl.json b/custom_components/parcelapp/tracking_data/dhl.json new file mode 100644 index 0000000..6bd176d --- /dev/null +++ b/custom_components/parcelapp/tracking_data/dhl.json @@ -0,0 +1,168 @@ +{ + "name": "DHL", + "courier_code": "dhl", + "tracking_numbers": [ + { + "name": "DHL Express", + "id": "dhl_express", + "regex": "\\s*(?([0-9]\\s*){9,10})(?([0-9]\\s*))", + "validation": { + "checksum": { + "name": "mod7" + } + }, + "tracking_url": "http://www.dhl.com/en/express/tracking.html?brand=DHL&AWB=%s", + "test_numbers": { + "valid": [ + "3318810025", + "73891051146", + "8487135506", + "1099255990", + "3821724944", + "3318810036", + "3318810014" + ], + "invalid": [ + "3318810010", + "3318810034", + "3318810011" + ] + } + }, + { + "name": "DHL Express (Piece ID)", + "id": "dhl_express_piece_id", + "regex": "\\s*(J[A-Z]{2,3})(?([0-9]\\s*){9,10})", + "validation": {}, + "tracking_url": "http://www.dhl.com/en/express/tracking.html?brand=DHL&AWB=%s", + "test_numbers": { + "valid": [ + "JJD0099999999", + "JVGL0999999990" + ], + "invalid": [ + "XJD0099999998" + ] + } + }, + { + "name": "DHL E-Commerce", + "id": "dhl_ecommerce", + "regex": "(?:GM|LX|RX|UV|CN|SG|TH|IN|HK|MY)\\s*(?(?=[0-9A-Z\\s]{10,39}\\b)(?=[^0-9A-Z]*[0-9])[0-9A-Z\\s]{10,39})", + "validation": {}, + "tracking_url": "http://www.dhl.com/en/express/tracking.html?brand=DHL&AWB=%s", + "test_numbers": { + "valid": [ + "GM2951173225174494", + "GM 2 9 5 117 32 25 1 7 44 9 4", + "GM295117494011169042", + "GM9E44608A27984866BA2D" + ], + "invalid": [ + "GS295117494011169041", + "GR295117494011169045", + "AAAAAAAAAAAAAAAAAA", + "ABCDEFGHILJKLMNOPQ", + "ABCDEFGHILJKLMNOPQRSTU1234567890" + ] + } + }, + { + "name": "DHL E-Commerce (14)", + "id": "dhl_ecommerce_14", + "regex": "\\s*\\b(?(?:[0-9]\\s*){14})\\b", + "validation": {}, + "tracking_url": "http://www.dhl.com/en/express/tracking.html?brand=DHL&AWB=%s", + "test_numbers": { + "valid": [ + "60120172242323", + "5108 7693037816", + "60120174971147" + ], + "invalid": [ + "A60120172242323", + "60120172242323B", + "160120172242323", + "6012017224232301", + "601201722X2323", + "GS295117494011169041", + "GR295117494011169045" + ] + } + }, + { + "name": "DHL E-Commerce (30)", + "id": "dhl_ecommerce_30", + "regex": [ + "\\s*(?4\\s*2\\s*0\\s*)(?([0-9]\\s*){5})", + "(?!\\s*9\\s*[1-5]\\b)", + "(?", + "(?(9[2-5]\\s*))", + "(?([0-9]\\s*){9})", + "(?([0-9]\\s*){10})", + ")", + "(?[0-9]\\s*)" + ], + "validation": { + "checksum": { + "name": "mod10", + "evens_multiplier": 3, + "odds_multiplier": 1, + "reverse": true + } + }, + "tracking_url": "https://ecommerceportal.dhl.com/track?trackingnumber=%s", + "partners": [{ + "description": "DHL eCommerce hands off to USPS for the last mile", + "partner_type": "carrier", + "partner_id": "usps_22", + "validation": { + "matches_all": [ + { "regex_group_name": "RoutingApplicationId", "matches": "420" }, + { "regex_group_name": "ServiceType", "matches_regex": "9[2345]" } + ] + } + }, + { + "description": "DHL eCommerce hands off to USPS for the last mile", + "partner_type": "carrier", + "partner_id": "usps_91", + "validation": { + "matches_all": [ + { "regex_group_name": "RoutingApplicationId", "matches": "420" }, + { "regex_group_name": "ServiceType", "matches_regex": "9[2345]" } + ] + } + }], + "additional": [ + { + "name": "Service Type", + "regex_group_name": "ServiceType", + "lookup": [ + { "matches": "92", "name": "USPS Tracking (Commercial)" }, + { "matches": "93", "name": "Collect on Delivery (COD)" }, + { "matches": "94", "name": "USPS Tracking" }, + { "matches": "95", "name": "Certified Mail" } + ] + } + ], + "test_numbers": { + "valid": [ + "420902459261290336128704042634", + "420941179261290336128704062441", + "420926029261290336128704067248", + "420902729261290336128704124163", + "420950549261290336128704133837", + "420193809261290336128704280838", + "420900469261290336128704216936" + ], + "invalid": [ + "420941179261290336128704062442", + "420941179261290336128704062400", + "420941179261290336128704062422" + ] + } + } + + ] +} diff --git a/custom_components/parcelapp/tracking_data/dpd.json b/custom_components/parcelapp/tracking_data/dpd.json new file mode 100644 index 0000000..2c5b0c1 --- /dev/null +++ b/custom_components/parcelapp/tracking_data/dpd.json @@ -0,0 +1,341 @@ +{ + "name": "DPD", + "courier_code": "dpd", + "tracking_numbers": [ + { + "name": "DPD (28)", + "id": "dpd", + "regex": [ + "\\s*", + "(?", + "(?([0-9]\\s*){7})", + "([0-9]\\s*){14}", + "(?([0-9]\\s*){3})", + "(?([0-9]\\s*){3})", + ")", + "(?[0-9A-Z]\\s*)" + ], + "validation": { + "checksum": { + "name": "mod_37_36" + } + }, + "tracking_url": "https://www.dpdgroup.com/nl/mydpd/my-parcels/track?lang=en&parcelNumber=%s", + "test_numbers": { + "valid": [ + "00 81827 0998 0000 0200 33 350 276 C", + "0081 827 0998 0000 0200 45 327 276 N" + ], + "invalid": [ + "008182709980000020033350276A", + "0081 827 0998 0000 0200 45 000 000 N" + ] + }, + "additional": [ + { + "name": "Service Type", + "regex_group_name": "ServiceType", + "lookup": [ + {"matches":"101","name":"D","description":"normal parcel"}, + {"matches":"102","name":"D-HAZ","description":"normal parcel, hazardous goods"}, + {"matches":"105","name":"D-EXW","description":"normal parcel, ex works"}, + {"matches":"106","name":"D-EXW-HAZ","description":"normal parcel, ex works, hazardous goods"}, + {"matches":"109","name":"D-COD","description":"normal parcel, C.O.D."}, + {"matches":"110","name":"D-COD-HAZ","description":"normal parcel, C.O.D., hazardous goods"}, + {"matches":"113","name":"D-SWAP","description":"normal parcel, exchange"}, + {"matches":"136","name":"D","description":"small parcel"}, + {"matches":"154","name":"PARCELLetter","description":"PARCELLetter"}, + {"matches":"155","name":"PM2","description":"Guarantee"}, + {"matches":"161","name":"PM2-COD","description":"Guarantee, C.O.D."}, + {"matches":"179","name":"AM1","description":"DPD 10:00"}, + {"matches":"191","name":"AM1-COD","description":"DPD 10:00, C.O.D."}, + {"matches":"225","name":"AM2","description":"DPD 12:00"}, + {"matches":"237","name":"AM2-COD","description":"DPD 12:00, C.O.D."}, + {"matches":"350","name":"AM0","description":"DPD 8:30"} + ] + }, + { + "name": "Country Code", + "regex_group_name": "CountryCode", + "lookup": [ + {"matches":"818","country_code":"EGY","country_short_code":"EG","country":"Aegypten"}, + {"matches":"226","country_code":"GNQ","country_short_code":"GQ","country":"Aequatorial-Guinea"}, + {"matches":"231","country_code":"ETH","country_short_code":"ET","country":"Aethiopien"}, + {"matches":"004","country_code":"AFG","country_short_code":"AF","country":"Afghanistan"}, + {"matches":"248","country_code":"ALA","country_short_code":"AX","country":"Aland-Inseln"}, + {"matches":"008","country_code":"ALB","country_short_code":"AL","country":"Albanien"}, + {"matches":"012","country_code":"DZA","country_short_code":"DZ","country":"Algerien"}, + {"matches":"016","country_code":"ASM","country_short_code":"AS","country":"Amerikanisch-Samoa"}, + {"matches":"020","country_code":"AND","country_short_code":"AD","country":"Andorra"}, + {"matches":"024","country_code":"AGO","country_short_code":"AO","country":"Angola"}, + {"matches":"660","country_code":"AIA","country_short_code":"AI","country":"Anguilla"}, + {"matches":"010","country_code":"ATA","country_short_code":"AQ","country":"Antarctica"}, + {"matches":"028","country_code":"ATG","country_short_code":"AG","country":"Antigua & Barbuda"}, + {"matches":"032","country_code":"ARG","country_short_code":"AR","country":"Argentinien"}, + {"matches":"051","country_code":"ARM","country_short_code":"AM","country":"Armenien"}, + {"matches":"533","country_code":"ABW","country_short_code":"AW","country":"Aruba"}, + {"matches":"031","country_code":"AZE","country_short_code":"AZ","country":"Aserbaidschan"}, + {"matches":"036","country_code":"AUS","country_short_code":"AU","country":"Australien"}, + {"matches":"044","country_code":"BHS","country_short_code":"BS","country":"Bahamas"}, + {"matches":"048","country_code":"BHR","country_short_code":"BH","country":"Bahrain"}, + {"matches":"050","country_code":"BGD","country_short_code":"BD","country":"Bangladesh"}, + {"matches":"052","country_code":"BRB","country_short_code":"BB","country":"Barbados"}, + {"matches":"056","country_code":"BEL","country_short_code":"BE","country":"Belgien"}, + {"matches":"084","country_code":"BLZ","country_short_code":"BZ","country":"Belize"}, + {"matches":"204","country_code":"BEN","country_short_code":"BJ","country":"Benin"}, + {"matches":"060","country_code":"BMU","country_short_code":"BM","country":"Bermudas"}, + {"matches":"064","country_code":"BTN","country_short_code":"BT","country":"Bhutan"}, + {"matches":"068","country_code":"BOL","country_short_code":"BO","country":"Bolivien"}, + {"matches":"535","country_code":"BES","country_short_code":"BQ","country":"Bonaire, Sint Eustatius und Saba"}, + {"matches":"070","country_code":"BIH","country_short_code":"BA","country":"Bosnien & Herzegowina"}, + {"matches":"072","country_code":"BWA","country_short_code":"BW","country":"Botswana"}, + {"matches":"074","country_code":"BVT","country_short_code":"BV","country":"Bouvet-Insel"}, + {"matches":"076","country_code":"BRA","country_short_code":"BR","country":"Brasilien"}, + {"matches":"086","country_code":"IOT","country_short_code":"IO","country":"British Indian Ocean Territory"}, + {"matches":"096","country_code":"BRN","country_short_code":"BN","country":"Brunei Darussalam"}, + {"matches":"100","country_code":"BGR","country_short_code":"BG","country":"Bulgarien"}, + {"matches":"854","country_code":"BFA","country_short_code":"BF","country":"Burkina Faso"}, + {"matches":"108","country_code":"BDI","country_short_code":"BI","country":"Burundi"}, + {"matches":"136","country_code":"CYM","country_short_code":"KY","country":"Cayman-Inseln"}, + {"matches":"152","country_code":"CHL","country_short_code":"CL","country":"Chile"}, + {"matches":"156","country_code":"CHN","country_short_code":"CN","country":"China"}, + {"matches":"184","country_code":"COK","country_short_code":"CK","country":"Cook Inseln"}, + {"matches":"188","country_code":"CRI","country_short_code":"CR","country":"Costa Rica"}, + {"matches":"531","country_code":"CUW","country_short_code":"CW","country":"Curacao"}, + {"matches":"208","country_code":"DNK","country_short_code":"DK","country":"Daenemark"}, + {"matches":"276","country_code":"DEU","country_short_code":"DE","country":"Deutschland"}, + {"matches":"212","country_code":"DMA","country_short_code":"DM","country":"Dominica"}, + {"matches":"214","country_code":"DOM","country_short_code":"DO","country":"Dominikanische Republik"}, + {"matches":"262","country_code":"DJI","country_short_code":"DJ","country":"Dschibuti"}, + {"matches":"218","country_code":"ECU","country_short_code":"EC","country":"Ecuador"}, + {"matches":"222","country_code":"SLV","country_short_code":"SV","country":"El Salvador"}, + {"matches":"384","country_code":"CIV","country_short_code":"CI","country":"Elfenbeinkueste"}, + {"matches":"232","country_code":"ERI","country_short_code":"ER","country":"Eritrea"}, + {"matches":"233","country_code":"EST","country_short_code":"EE","country":"Estland"}, + {"matches":"234","country_code":"FRO","country_short_code":"FO","country":"Faeroer Inseln"}, + {"matches":"238","country_code":"FLK","country_short_code":"FK","country":"Falkland Inseln"}, + {"matches":"242","country_code":"FJI","country_short_code":"FJ","country":"Fidschi"}, + {"matches":"246","country_code":"FIN","country_short_code":"FI","country":"Finnland"}, + {"matches":"250","country_code":"FRA","country_short_code":"FR","country":"Frankreich"}, + {"matches":"260","country_code":"ATF","country_short_code":"TF","country":"Franzoesische Sued- und Antarktisterritorien"}, + {"matches":"258","country_code":"PYF","country_short_code":"PF","country":"Franzoesisch-Polynesien"}, + {"matches":"266","country_code":"GAB","country_short_code":"GA","country":"Gabun"}, + {"matches":"270","country_code":"GMB","country_short_code":"GM","country":"Gambia"}, + {"matches":"268","country_code":"GEO","country_short_code":"GE","country":"Georgien"}, + {"matches":"288","country_code":"GHA","country_short_code":"GH","country":"Ghana"}, + {"matches":"292","country_code":"GIB","country_short_code":"GI","country":"Gibraltar"}, + {"matches":"308","country_code":"GRD","country_short_code":"GD","country":"Grenada"}, + {"matches":"300","country_code":"GRC","country_short_code":"GR","country":"Griechenland"}, + {"matches":"304","country_code":"GRL","country_short_code":"GL","country":"Groenland"}, + {"matches":"826","country_code":"GBR","country_short_code":"GB","country":"Grossbritannien & Nordirland"}, + {"matches":"312","country_code":"GLP","country_short_code":"GP","country":"Guadeloupe"}, + {"matches":"316","country_code":"GUM","country_short_code":"GU","country":"Guam"}, + {"matches":"320","country_code":"GTM","country_short_code":"GT","country":"Guatemala"}, + {"matches":"831","country_code":"GGY","country_short_code":"GG","country":"Guernsey"}, + {"matches":"324","country_code":"GIN","country_short_code":"GN","country":"Guinea"}, + {"matches":"624","country_code":"GNB","country_short_code":"GW","country":"Guinea-Bissau"}, + {"matches":"328","country_code":"GUY","country_short_code":"GY","country":"Guyana"}, + {"matches":"254","country_code":"GUF","country_short_code":"GF","country":"Guyana (Franzoesisch)"}, + {"matches":"332","country_code":"HTI","country_short_code":"HT","country":"Haiti"}, + {"matches":"334","country_code":"HMD","country_short_code":"HM","country":"Heard & Mc Donalds Inseln"}, + {"matches":"340","country_code":"HND","country_short_code":"HN","country":"Honduras"}, + {"matches":"344","country_code":"HKG","country_short_code":"HK","country":"Hong Kong"}, + {"matches":"356","country_code":"IND","country_short_code":"IN","country":"Indien"}, + {"matches":"360","country_code":"IDN","country_short_code":"ID","country":"Indonesien"}, + {"matches":"364","country_code":"IRN","country_short_code":"IR","country":"Iran"}, + {"matches":"368","country_code":"IRQ","country_short_code":"IQ","country":"Iraq"}, + {"matches":"372","country_code":"IRL","country_short_code":"IE","country":"Irland"}, + {"matches":"352","country_code":"ISL","country_short_code":"IS","country":"Island"}, + {"matches":"833","country_code":"IMN","country_short_code":"IM","country":"Isle of Man"}, + {"matches":"376","country_code":"ISR","country_short_code":"IL","country":"Israel"}, + {"matches":"380","country_code":"ITA","country_short_code":"IT","country":"Italien"}, + {"matches":"388","country_code":"JAM","country_short_code":"JM","country":"Jamaika"}, + {"matches":"392","country_code":"JPN","country_short_code":"JP","country":"Japan"}, + {"matches":"887","country_code":"YEM","country_short_code":"YE","country":"Jemen"}, + {"matches":"832","country_code":"JEY","country_short_code":"JE","country":"Jersey"}, + {"matches":"400","country_code":"JOR","country_short_code":"JO","country":"Jordanien"}, + {"matches":"092","country_code":"VGB","country_short_code":"VG","country":"Jungferninseln (britisch)"}, + {"matches":"116","country_code":"KHM","country_short_code":"KH","country":"Kambodscha"}, + {"matches":"120","country_code":"CMR","country_short_code":"CM","country":"Kamerun"}, + {"matches":"124","country_code":"CAN","country_short_code":"CA","country":"Kanada"}, + {"matches":"991","country_code":"ISC","country_short_code":"IC","country":"Kanarische Inseln"}, + {"matches":"132","country_code":"CPV","country_short_code":"CV","country":"Kapverdische Inseln"}, + {"matches":"583","country_code":"FSM","country_short_code":"FM","country":"Karolinen Inseln"}, + {"matches":"398","country_code":"KAZ","country_short_code":"KZ","country":"Kasachstan"}, + {"matches":"634","country_code":"QAT","country_short_code":"QA","country":"Katar"}, + {"matches":"404","country_code":"KEN","country_short_code":"KE","country":"Kenia"}, + {"matches":"417","country_code":"KGZ","country_short_code":"KG","country":"Kirgistan"}, + {"matches":"296","country_code":"KIR","country_short_code":"KI","country":"Kiribati"}, + {"matches":"581","country_code":"UMI","country_short_code":"UM","country":"Kleine vorgelagerte Inseln Vereinigter Staaten"}, + {"matches":"166","country_code":"CCK","country_short_code":"CC","country":"Kokos Inseln"}, + {"matches":"170","country_code":"COL","country_short_code":"CO","country":"Kolumbien"}, + {"matches":"174","country_code":"COM","country_short_code":"KM","country":"Komoren"}, + {"matches":"178","country_code":"COG","country_short_code":"CG","country":"Kongo"}, + {"matches":"180","country_code":"COD","country_short_code":"CD","country":"Kongo, Dem. Rep."}, + {"matches":"191","country_code":"HRV","country_short_code":"HR","country":"Kroatien"}, + {"matches":"192","country_code":"CUB","country_short_code":"CU","country":"Kuba"}, + {"matches":"414","country_code":"KWT","country_short_code":"KW","country":"Kuwait"}, + {"matches":"418","country_code":"LAO","country_short_code":"LA","country":"Laos"}, + {"matches":"426","country_code":"LSO","country_short_code":"LS","country":"Lesotho"}, + {"matches":"428","country_code":"LVA","country_short_code":"LV","country":"Lettland"}, + {"matches":"422","country_code":"LBN","country_short_code":"LB","country":"Libanon"}, + {"matches":"430","country_code":"LBR","country_short_code":"LR","country":"Liberia"}, + {"matches":"434","country_code":"LBY","country_short_code":"LY","country":"Libyen"}, + {"matches":"438","country_code":"LIE","country_short_code":"LI","country":"Liechtenstein"}, + {"matches":"440","country_code":"LTU","country_short_code":"LT","country":"Litauen"}, + {"matches":"442","country_code":"LUX","country_short_code":"LU","country":"Luxemburg"}, + {"matches":"446","country_code":"MAC","country_short_code":"MO","country":"Macao"}, + {"matches":"450","country_code":"MDG","country_short_code":"MG","country":"Madagaskar"}, + {"matches":"454","country_code":"MWI","country_short_code":"MW","country":"Malawi"}, + {"matches":"458","country_code":"MYS","country_short_code":"MY","country":"Malaysia"}, + {"matches":"462","country_code":"MDV","country_short_code":"MV","country":"Malediven"}, + {"matches":"466","country_code":"MLI","country_short_code":"ML","country":"Mali"}, + {"matches":"470","country_code":"MLT","country_short_code":"MT","country":"Malta"}, + {"matches":"504","country_code":"MAR","country_short_code":"MA","country":"Marokko"}, + {"matches":"584","country_code":"MHL","country_short_code":"MH","country":"Marshall Inseln"}, + {"matches":"474","country_code":"MTQ","country_short_code":"MQ","country":"Martinique"}, + {"matches":"478","country_code":"MRT","country_short_code":"MR","country":"Mauretanien"}, + {"matches":"480","country_code":"MUS","country_short_code":"MU","country":"Mauritius"}, + {"matches":"175","country_code":"MYT","country_short_code":"YT","country":"Mayotte"}, + {"matches":"807","country_code":"MKD","country_short_code":"MK","country":"Mazedonien"}, + {"matches":"484","country_code":"MEX","country_short_code":"MX","country":"Mexiko"}, + {"matches":"498","country_code":"MDA","country_short_code":"MD","country":"Moldawien"}, + {"matches":"492","country_code":"MCO","country_short_code":"MC","country":"Monaco"}, + {"matches":"496","country_code":"MNG","country_short_code":"MN","country":"Mongolei"}, + {"matches":"499","country_code":"MNE","country_short_code":"ME","country":"Montenegro"}, + {"matches":"500","country_code":"MSR","country_short_code":"MS","country":"Montserrat"}, + {"matches":"508","country_code":"MOZ","country_short_code":"MZ","country":"Mosambik"}, + {"matches":"104","country_code":"MMR","country_short_code":"MM","country":"Myanmar"}, + {"matches":"516","country_code":"NAM","country_short_code":"NA","country":"Namibia"}, + {"matches":"520","country_code":"NRU","country_short_code":"NR","country":"Nauru"}, + {"matches":"524","country_code":"NPL","country_short_code":"NP","country":"Nepal"}, + {"matches":"540","country_code":"NCL","country_short_code":"NC","country":"Neukaledonien"}, + {"matches":"554","country_code":"NZL","country_short_code":"NZ","country":"Neuseeland"}, + {"matches":"558","country_code":"NIC","country_short_code":"NI","country":"Nicaragua"}, + {"matches":"530","country_code":"ANT","country_short_code":"AN","country":"Niederlaendische Antillen"}, + {"matches":"528","country_code":"NLD","country_short_code":"NL","country":"Niederlande"}, + {"matches":"562","country_code":"NER","country_short_code":"NE","country":"Niger"}, + {"matches":"566","country_code":"NGA","country_short_code":"NG","country":"Nigeria"}, + {"matches":"570","country_code":"NIU","country_short_code":"NU","country":"Niue"}, + {"matches":"580","country_code":"MNP","country_short_code":"MP","country":"Noerdliche Marianen"}, + {"matches":"408","country_code":"PRK","country_short_code":"KP","country":"Nordkorea"}, + {"matches":"574","country_code":"NFK","country_short_code":"NF","country":"Norfolk Inseln"}, + {"matches":"578","country_code":"NOR","country_short_code":"NO","country":"Norwegen"}, + {"matches":"040","country_code":"AUT","country_short_code":"AT","country":"Oesterreich"}, + {"matches":"512","country_code":"OMN","country_short_code":"OM","country":"Oman"}, + {"matches":"626","country_code":"TLS","country_short_code":"TL","country":"Osttimor"}, + {"matches":"586","country_code":"PAK","country_short_code":"PK","country":"Pakistan"}, + {"matches":"275","country_code":"PSE","country_short_code":"PS","country":"Palaestina"}, + {"matches":"585","country_code":"PLW","country_short_code":"PW","country":"Palau"}, + {"matches":"591","country_code":"PAN","country_short_code":"PA","country":"Panama"}, + {"matches":"598","country_code":"PNG","country_short_code":"PG","country":"Papua-Neuguinea"}, + {"matches":"600","country_code":"PRY","country_short_code":"PY","country":"Paraguay"}, + {"matches":"604","country_code":"PER","country_short_code":"PE","country":"Peru"}, + {"matches":"608","country_code":"PHL","country_short_code":"PH","country":"Philippinen"}, + {"matches":"612","country_code":"PCN","country_short_code":"PN","country":"Pitcairn"}, + {"matches":"616","country_code":"POL","country_short_code":"PL","country":"Polen"}, + {"matches":"620","country_code":"PRT","country_short_code":"PT","country":"Portugal"}, + {"matches":"630","country_code":"PRI","country_short_code":"PR","country":"Puerto Rico"}, + {"matches":"638","country_code":"REU","country_short_code":"RE","country":"Reunion"}, + {"matches":"646","country_code":"RWA","country_short_code":"RW","country":"Ruanda"}, + {"matches":"642","country_code":"ROU","country_short_code":"RO","country":"Rumaenien"}, + {"matches":"643","country_code":"RUS","country_short_code":"RU","country":"Russland"}, + {"matches":"663","country_code":"MAF","country_short_code":"MF","country":"Saint Martin"}, + {"matches":"894","country_code":"ZMB","country_short_code":"ZM","country":"Samibia"}, + {"matches":"882","country_code":"WSM","country_short_code":"WS","country":"Samoa"}, + {"matches":"674","country_code":"SMR","country_short_code":"SM","country":"San Marino"}, + {"matches":"678","country_code":"STP","country_short_code":"ST","country":"Sao Tome & Principe"}, + {"matches":"682","country_code":"SAU","country_short_code":"SA","country":"Saudi Arabien"}, + {"matches":"752","country_code":"SWE","country_short_code":"SE","country":"Schweden"}, + {"matches":"756","country_code":"CHE","country_short_code":"CH","country":"Schweiz"}, + {"matches":"686","country_code":"SEN","country_short_code":"SN","country":"Senegal"}, + {"matches":"688","country_code":"SRB","country_short_code":"RS","country":"Serbien"}, + {"matches":"690","country_code":"SYC","country_short_code":"SC","country":"Seychellen"}, + {"matches":"694","country_code":"SLE","country_short_code":"SL","country":"Sierra Leone"}, + {"matches":"716","country_code":"ZWE","country_short_code":"ZW","country":"Simbabwe"}, + {"matches":"702","country_code":"SGP","country_short_code":"SG","country":"Singapur"}, + {"matches":"534","country_code":"SXM","country_short_code":"SX","country":"Sint Maarten (niederlaendischer Teil)"}, + {"matches":"703","country_code":"SVK","country_short_code":"SK","country":"Slowakei"}, + {"matches":"705","country_code":"SVN","country_short_code":"SI","country":"Slowenien"}, + {"matches":"090","country_code":"SLB","country_short_code":"SB","country":"Solomon Inseln"}, + {"matches":"706","country_code":"SOM","country_short_code":"SO","country":"Somalia"}, + {"matches":"724","country_code":"ESP","country_short_code":"ES","country":"Spanien"}, + {"matches":"144","country_code":"LKA","country_short_code":"LK","country":"Sri Lanka"}, + {"matches":"654","country_code":"SHN","country_short_code":"SH","country":"St. Helena"}, + {"matches":"659","country_code":"KNA","country_short_code":"KN","country":"St. Kitts und Nevis"}, + {"matches":"662","country_code":"LCA","country_short_code":"LC","country":"St. Lucia"}, + {"matches":"666","country_code":"SPM","country_short_code":"PM","country":"St. Pierre & Miquelon"}, + {"matches":"670","country_code":"VCT","country_short_code":"VC","country":"St. Vincent und die Grenadinen"}, + {"matches":"736","country_code":"SDN","country_short_code":"SD","country":"Sudan"}, + {"matches":"710","country_code":"ZAF","country_short_code":"ZA","country":"Suedafrika"}, + {"matches":"239","country_code":"SGS","country_short_code":"GS","country":"Suedgeorgien und die Suedlichen Sandwichinseln"}, + {"matches":"410","country_code":"KOR","country_short_code":"KR","country":"Suedkorea"}, + {"matches":"728","country_code":"SSD","country_short_code":"SS","country":"Suedsudan"}, + {"matches":"740","country_code":"SUR","country_short_code":"SR","country":"Suriname"}, + {"matches":"744","country_code":"SJM","country_short_code":"SJ","country":"Svalbard & Jan Mayen Inseln"}, + {"matches":"748","country_code":"SWZ","country_short_code":"SZ","country":"Swasiland"}, + {"matches":"760","country_code":"SYR","country_short_code":"SY","country":"Syrien"}, + {"matches":"762","country_code":"TJK","country_short_code":"TJ","country":"Tadschikistan"}, + {"matches":"158","country_code":"TWN","country_short_code":"TW","country":"Taiwan"}, + {"matches":"834","country_code":"TZA","country_short_code":"TZ","country":"Tansania"}, + {"matches":"764","country_code":"THA","country_short_code":"TH","country":"Thailand"}, + {"matches":"768","country_code":"TGO","country_short_code":"TG","country":"Togo"}, + {"matches":"772","country_code":"TKL","country_short_code":"TK","country":"Tokelau"}, + {"matches":"776","country_code":"TON","country_short_code":"TO","country":"Tonga"}, + {"matches":"780","country_code":"TTO","country_short_code":"TT","country":"Trinidad & Tobago"}, + {"matches":"148","country_code":"TCD","country_short_code":"TD","country":"Tschad"}, + {"matches":"203","country_code":"CZE","country_short_code":"CZ","country":"Tschechien (Republik)"}, + {"matches":"792","country_code":"TUR","country_short_code":"TR","country":"Tuerkei"}, + {"matches":"788","country_code":"TUN","country_short_code":"TN","country":"Tunesien"}, + {"matches":"795","country_code":"TKM","country_short_code":"TM","country":"Turkmenistan"}, + {"matches":"796","country_code":"TCA","country_short_code":"TC","country":"Turks & Caicos-Inseln"}, + {"matches":"798","country_code":"TUV","country_short_code":"TV","country":"Tuvalu"}, + {"matches":"800","country_code":"UGA","country_short_code":"UG","country":"Uganda"}, + {"matches":"804","country_code":"UKR","country_short_code":"UA","country":"Ukraine"}, + {"matches":"348","country_code":"HUN","country_short_code":"HU","country":"Ungarn"}, + {"matches":"858","country_code":"URY","country_short_code":"UY","country":"Uruguay"}, + {"matches":"850","country_code":"VIR","country_short_code":"VI","country":"US Virgin Islands"}, + {"matches":"840","country_code":"USA","country_short_code":"US","country":"USA"}, + {"matches":"860","country_code":"UZB","country_short_code":"UZ","country":"Usbekistan"}, + {"matches":"548","country_code":"VUT","country_short_code":"VU","country":"Vanuatu"}, + {"matches":"336","country_code":"VAT","country_short_code":"VA","country":"Vatikan"}, + {"matches":"862","country_code":"VEN","country_short_code":"VE","country":"Venezuela"}, + {"matches":"784","country_code":"ARE","country_short_code":"AE","country":"Vereinigte Arabische Emirate"}, + {"matches":"704","country_code":"VNM","country_short_code":"VN","country":"Vietnam"}, + {"matches":"876","country_code":"WLF","country_short_code":"WF","country":"Wallis & Futuna"}, + {"matches":"162","country_code":"CXR","country_short_code":"CX","country":"Weihnachtsinseln"}, + {"matches":"112","country_code":"BLR","country_short_code":"BY","country":"Weissrussland"}, + {"matches":"732","country_code":"ESH","country_short_code":"EH","country":"West Sahara"}, + {"matches":"140","country_code":"CAF","country_short_code":"CF","country":"Zentralafrika"}, + {"matches":"196","country_code":"CYP","country_short_code":"CY","country":"Zypern"} + ] + } + ] + }, + { + "name": "DPD (14)", + "id": "dpd_14", + "regex": [ + "\\s*(?", + "([0-9]\\s*){14}", + ")", + "(?[0-9A-Z]\\s*)" + ], + "validation": { + "checksum": { + "name": "mod_37_36" + } + }, + "tracking_url": "https://www.dpdgroup.com/nl/mydpd/my-parcels/track?lang=en&parcelNumber=%s", + "test_numbers": { + "valid": [ + "09 9800 0002 0033 F", + "0998 0000 0200 34D" + ], + "invalid": [ + "09980000020033D" + ] + } + } + ] +} diff --git a/custom_components/parcelapp/tracking_data/fedex.json b/custom_components/parcelapp/tracking_data/fedex.json new file mode 100644 index 0000000..72df4a8 --- /dev/null +++ b/custom_components/parcelapp/tracking_data/fedex.json @@ -0,0 +1,303 @@ +{ + "name": "FedEx", + "courier_code": "fedex", + "tracking_numbers": [ + { + "name": "FedEx Express (12)", + "id": "fedex_12", + "regex": "\\s*(?([0-9]\\s*){11})(?[0-9]\\s*)", + "validation": { + "checksum": { + "name": "sum_product_with_weightings_and_modulo", + "weightings": [ + 3, + 1, + 7, + 3, + 1, + 7, + 3, + 1, + 7, + 3, + 1 + ], + "modulo1": 11, + "modulo2": 10 + } + }, + "tracking_url": "https://www.fedex.com/apps/fedextrack/?tracknumbers=%s", + "test_numbers": { + "valid": [ + "986578788855", + "477179081230", + "799531274483", + "790535312317", + " 7 9 0 5 3 5 3 1 2 3 1 7 ", + "974367662710" + ], + "invalid": [ + "996578788855" + ] + } + }, + { + "name": "FedEx Express (34)", + "id": "fedex_34", + "regex": [ + "\\s*1\\s*0\\s*[0-9]\\s*[0-9]\\s*[0-9]\\s*", + "([0-9]\\s*){10}", + "(?([0-9]\\s*){5})", + "(?([0-9]\\s*){13})", + "(?[0-9]\\s*)" + ], + "validation": { + "checksum": { + "name": "sum_product_with_weightings_and_modulo", + "weightings": [ + 1, + 7, + 3, + 1, + 7, + 3, + 1, + 7, + 3, + 1, + 7, + 3, + 1 + ], + "modulo1": 11, + "modulo2": 10 + } + }, + "tracking_url": "https://www.fedex.com/apps/fedextrack/?tracknumbers=%s", + "test_numbers": { + "valid": [ + "1001921334250001000300779017972697", + "1001921380360001000300639585804382", + "1001901781990001000300617767839437", + " 1 0 0 1 9 0 1 7 8 1 9 9 0 0 0 1 0 0 0 3 0 0 6 1 7 7 6 7 8 3 9 4 3 7 ", + "1002297871540001000300790695517286", + "1027590111820004833500785458233610" + ], + "invalid": [ + "1001901781990001000300617767839438" + ] + } + }, + { + "name": "FedEx SmartPost", + "id": "fedex_smartpost", + "description": "Shipped by FedEx, Delivered by USPS", + "regex": [ + "\\s*(?:", + "(?:(?4\\s*2\\s*0\\s*)(?([0-9]\\s*){5}))?", + "(?9\\s*2\\s*)", + ")?", + "(?", + "(?6\\s*1\\s*)", + "(?2\\s*9\\s*)", + "(?([0-9]\\s*){8})", + "(?([0-9]\\s*){11}|([0-9]\\s*){7})", + ")", + "(?([0-9]\\s*))" + ], + "additional": [ + { + "name": "Service Type", + "regex_group_name": "ServiceType", + "lookup": [ + { + "matches_regex": ".", + "name": "Delivered by USPS" + } + ] + } + ], + "partners": [{ + "partner_id": "usps_91", + "partner_type": "carrier", + "description": "FedEx SmartPost is a shipping service that utilizes FedEx for the initial transport and the United States Postal Service for final delivery." + }], + "validation": { + "checksum": { + "name": "mod10", + "evens_multiplier": 3, + "odds_multiplier": 1 + }, + "serial_number_format": { + "prepend_if": { + "matches_regex": "^(?!92).+", + "content": "92" + } + } + }, + "tracking_url": "https://www.fedex.com/apps/fedextrack/?tracknumbers=%s", + "test_numbers": { + "valid": [ + "9261292700768711948021", + "420 11213 92 6129098349792366623 8", + "92 6129098349792366623 8" + ], + "invalid": [ + "9261292700768711948020", + "92001903060085300042901077", + "420 11213 61290983497923666231", + "92 6129098349792366623 5", + "9400 1112 0108 0805 4830 16", + "9361 2898 7870 0317 6337 95" + ] + } + }, + { + "name": "FedEx Ground", + "id": "fedex_ground", + "regex": "\\s*(?([0-9]\\s*){14})(?([0-9]\\s*))", + "validation": { + "checksum": { + "name": "mod10", + "evens_multiplier": 1, + "odds_multiplier": 3 + } + }, + "tracking_url": "https://www.fedex.com/apps/fedextrack/?tracknumbers=%s", + "test_numbers": { + "valid": [ + "0414 4176 0228 964", + "5682 8361 0012 000", + " 5 6 8 2 8 3 6 1 0 0 1 2 0 0 0 ", + "5682 8361 0012 734" + ], + "invalid": [ + "5682 8361 0012 732" + ] + } + }, + { + "name": "FedEx Ground (SSCC-18)", + "id": "fedex_ground_sscc_18", + "regex": "\\s*(?([0-9]\\s*){2})(?([0-9]\\s*){15})(?[0-9]\\s*)", + "tracking_url": "https://www.fedex.com/apps/fedextrack/?tracknumbers=%s", + "validation": { + "checksum": { + "name": "mod10", + "evens_multiplier": 3, + "odds_multiplier": 1 + } + }, + "test_numbers": { + "valid": [ + "00 0123 4500 0000 0027", + " 0 0 0 1 2 3 4 5 0 0 0 0 0 0 0 0 2 7 " + ], + "invalid": [ + "000000000000000001" + ] + }, + "additional": [ + { + "name": "Container Type", + "regex_group_name": "ShippingContainerType", + "lookup": [ + { + "matches": "00", + "name": "case/carton" + }, + { + "matches": "01", + "name": "pallet" + }, + { + "matches": "02", + "name": "larger than a pallet" + }, + { + "matches": "04", + "name": "internally defined for intra-company use" + } + ] + } + ] + }, + { + "name": "FedEx Ground 96 (22)", + "id": "fedex_ground_96", + "regex": [ + "\\s*(?9\\s*6\\s*)", + "(?([0-9]\\s*){2})", + "(?([0-9]\\s*){3})", + "(?(?([0-9]\\s*){7})(?([0-9]\\s*){7}))", + "(?[0-9]\\s*)" + ], + "validation": { + "checksum": { + "name": "mod10", + "evens_multiplier": 1, + "odds_multiplier": 3 + } + }, + "tracking_url": "https://www.fedex.com/apps/fedextrack/?tracknumbers=%s", + "test_numbers": { + "valid": [ + "9611020987654312345672", + " 9 6 1 1 0 2 0 9 8 7 6 5 4 3 1 2 3 4 5 6 7 2 " + ], + "invalid": [ + "9600000000000000000001" + ] + } + }, + { + "name": "FedEx Ground GSN", + "id": "fedex_ground_gsn", + "regex": [ + "\\s*(?9\\s*6\\s*)", + "(?([0-9]\\s*){2})", + "([0-9]\\s*){5}", + "(?([0-9]\\s*){10})", + "[0-9]\\s*", + "(?([0-9]\\s*){13})", + "(?[0-9]\\s*)" + ], + "validation": { + "checksum": { + "name": "sum_product_with_weightings_and_modulo", + "weightings": [ + 1, + 7, + 3, + 1, + 7, + 3, + 1, + 7, + 3, + 1, + 7, + 3, + 1 + ], + "modulo1": 11, + "modulo2": 10 + } + }, + "tracking_url": "https://www.fedex.com/apps/fedextrack/?tracknumbers=%s", + "test_numbers": { + "valid": [ + "9622001900000000000000776632517510", + "9622001560000000000000794808390594", + "9622001560001234567100794808390594", + " 9 6 2 2 0 0 1 5 6 0 0 0 1 2 3 4 5 6 7 1 0 0 7 9 4 8 0 8 3 9 0 5 9 4 ", + "9632001560123456789900794808390594" + ], + "invalid": [ + "9622001560001234567100794808390595", + "9622001560001234567100794808390597" + ] + } + } + ] +} diff --git a/custom_components/parcelapp/tracking_data/landmark.json b/custom_components/parcelapp/tracking_data/landmark.json new file mode 100644 index 0000000..88992be --- /dev/null +++ b/custom_components/parcelapp/tracking_data/landmark.json @@ -0,0 +1,26 @@ +{ + "name": "Landmark Global LTN", + "courier_code": "landmark", + "tracking_numbers": [ + { + "name": "Landmark Global LTN", + "id": "landmark_global", + "regex": [ + "\\s*L\\s*T\\s*N\\s*(?([0-9]\\s*){8})\\s*N\\s*1" + ], + "tracking_url": "https://track.landmarkglobal.com/?search=%s", + "validation": {}, + "test_numbers": { + "valid": [ + "LTN74207623N1", + "LTN74209518N1", + "LTN74224021N1" + ], + "invalid": [ + "LSN74209518N2", + "LSN74209518N1" + ] + } + } + ] +} diff --git a/custom_components/parcelapp/tracking_data/lasership.json b/custom_components/parcelapp/tracking_data/lasership.json new file mode 100644 index 0000000..86b2d87 --- /dev/null +++ b/custom_components/parcelapp/tracking_data/lasership.json @@ -0,0 +1,84 @@ +{ + "name": "LaserShip", + "courier_code": "lasership", + "tracking_numbers": [ + { + "name": "LaserShip LX", + "id": "lasership_lx", + "regex": [ + "\\s*L\\s*[AIEHNX]\\s*[1-3]\\s*(?([0-9]\\s*){7,7})\\s*" + ], + "validation": {}, + "test_numbers": { + "valid": [ + "LX17635036", + "LX 176 35035", + "LX17635034", + "LI 129 79072", + "LI12976442", + "LA28376237", + "LA28372694", + "LH13830790", + "LH13816137", + "LH13820469", + "LH13831034", + "LH13821737", + "LH13820881", + "LH13820881", + "LH13812209", + "LH13800911", + "LH13795254", + "LE10917377", + "LE10913900", + "LE10913753", + "LN30083672" + ], + "invalid": [ + "LX9763503N", + "LH9176350N6", + "XA17635036", + "L A 9 7 6 3 5 0 3 6 " + ] + } + }, + { + "name": "LaserShip 1LS7 (15)", + "id": "lasership_1ls7", + "regex": [ + "\\s*1\\s*L\\s*S\\s*7\\s*[12]\\s*([0-9]\\s*){4,4}", + "(?([0-9]\\s*){6,6})\\s*" + ], + "validation": {}, + "test_numbers": { + "valid": [ + "1LS717793482164", + "1LS724505321754", + "1LS720000000000", + " 1 L S 7 2 0 0 0 0 0 0 0 0 0 0 " + ], + "invalid": [ + "1LX734505321754" + ] + } + }, + { + "name": "LaserShip 1LS7 (18)", + "regex": [ + "\\s*1\\s*L\\s*S\\s*7\\s*", + "[12]\\s*([0-9]\\s*){2,2}\\s*0\\s*1\\s*[1234]\\s*", + "\\s*(?([0-9]\\s*){6,6})", + "-\\s*1\\s*" + ], + "validation": {}, + "test_numbers": { + "valid": [ + "1LS7119013618127-1", + " 1 L S 7 1 1 9 0 1 3 6 1 8 1 2 7 - 1 " + ], + "invalid": [ + "1LS7119013618127-2" + ] + } + } + ] +} diff --git a/custom_components/parcelapp/tracking_data/old_dominion.json b/custom_components/parcelapp/tracking_data/old_dominion.json new file mode 100644 index 0000000..3b804ee --- /dev/null +++ b/custom_components/parcelapp/tracking_data/old_dominion.json @@ -0,0 +1,63 @@ +{ + "name": "Old Dominion Freight Line", + "courier_code": "old_dominion", + "tracking_numbers": [ + { + "name": "Old Dominion", + "regex": [ + "\\s*(?", + "(7\\s*7\\s*[78]\\s*|0\\s*7\\s*2\\s*|7\\s*8\\s*0\\s*)", + "([0-9]\\s*){7}", + ")", + "(?[0-9]\\s*)" + ], + "validation": { + "checksum": { + "name": "luhn" + } + }, + "tracking_url": "https://www.odfl.com/us/en/tools/trace-track-ltl-freight/trace.html?proNumbers=%s", + "test_numbers": { + "valid": [ + "07209562763", + " 0 7 2 0 9 5 6 2 7 6 3 ", + "77767553207", + "77806528897", + "78045768393" + ], + "invalid": [ + "07209562773", + "79927398713", + "10000000000" + ] + } + }, + { + "name": "Old Dominion Guaranteed Shipment", + "regex": [ + "\\s*(?", + "8\\s*0\\s*", + "([0-9]\\s*){8}", + ")", + "(?[0-9]\\s*)" + ], + "validation": { + "checksum": { + "name": "luhn" + } + }, + "tracking_url": "https://www.odfl.com/us/en/tools/trace-track-ltl-freight/trace.html?proNumbers=%s", + "test_numbers": { + "valid": [ + "80003280379", + " 8 0 0 0 3 2 8 0 3 7 9 ", + "80993847369" + ], + "invalid": [ + "80003280389", + "10000000000" + ] + } + } + ] +} diff --git a/custom_components/parcelapp/tracking_data/ontrac.json b/custom_components/parcelapp/tracking_data/ontrac.json new file mode 100644 index 0000000..30ffb82 --- /dev/null +++ b/custom_components/parcelapp/tracking_data/ontrac.json @@ -0,0 +1,70 @@ +{ + "name": "OnTrac", + "courier_code": "ontrac", + "tracking_numbers": [ + { + "name": "OnTrac", + "id": "ontrac_c", + "regex": "\\s*C\\s*(?([0-9]\\s*){13})(?[0-9]\\s*)", + "validation": { + "checksum": { + "name": "mod10", + "evens_multiplier": 1, + "odds_multiplier": 2 + }, + "serial_number_format": { + "prepend_if": { + "matches_regex": "^(?!4).+$", + "content": "4" + } + } + }, + "tracking_url": "http://www.ontrac.com/tracking/?number=%s", + "test_numbers": { + "valid": [ + "C11031500001879", + "C 110 31 500 00187 9", + "C10999911320231", + "C11121552953069", + "C11121553156000", + "C11121552829468" + ], + "invalid": [ + "C10000000000000", + "C11031500001889" + ] + } + }, + { + "name": "OnTrac D", + "id": "ontrac_d", + "regex": "\\s*D\\s*(?([0-9]\\s*){13})(?[0-9]\\s*)", + "validation": { + "checksum": { + "name": "mod10", + "evens_multiplier": 1, + "odds_multiplier": 2 + }, + "serial_number_format": { + "prepend_if": { + "matches_regex": "^(?!5).+$", + "content": "5" + } + } + }, + "tracking_url": "http://www.ontrac.com/tracking/?number=%s", + "test_numbers": { + "valid": [ + "D10011354453707", + "D10011345983010", + "D 100 113 459 830 10", + "D10011342332145" + ], + "invalid": [ + "D10011345983012", + "D10011342332144" + ] + } + } + ] +} diff --git a/custom_components/parcelapp/tracking_data/s10.json b/custom_components/parcelapp/tracking_data/s10.json new file mode 100644 index 0000000..db7e12c --- /dev/null +++ b/custom_components/parcelapp/tracking_data/s10.json @@ -0,0 +1,1435 @@ +{ + "name": "S10 International Standard", + "courier_code": "s10", + "tracking_numbers": [ + { + "id": "s10", + "name": "S10", + "validation": { + "checksum": { + "name": "s10" + }, + "additional": { + "exists": [ + "Courier" + ] + } + }, + "regex": "\\s*(?([A-Z]\\s*){2})(?([0-9]\\s*){8})(?([0-9]\\s*))(?([A-Z]\\s*){2})", + "tracking_url": null, + "test_numbers": { + "valid": [ + "RB123456785GB", + "RB123456785US", + "RB123456785CV", + "RB123456785CF" + ], + "invalid": [ + "RB123456786US", + "RB123456785XX" + ] + }, + "additional": [ + { + "name": "Service Type", + "regex_group_name": "ServiceType", + "lookup": [ + { + "name": "EMS", + "matches_regex": "E[A-Z]", + "description": "International Express Mail Service" + }, + { + "name": "Letter Post Express", + "matches_regex": "L[A-Z]", + "description": "" + }, + { + "name": "Letter Post M-bag", + "matches_regex": "M[A-Z]", + "description": "Direct sacks of printed matter sent to a single foreign addressee at a single address" + }, + { + "name": "Letter Post IBRS", + "matches_regex": "Q[A-M]", + "description": "International Business Reply Service" + }, + { + "name": "Letter Post Registered", + "matches_regex": "R[A-Z]", + "description": "Prepaid first-class mail that is recorded by the post office before being sent and at each point along its route to safeguard against loss, theft, or damage." + }, + { + "name": "Letter Post Misc", + "matches_regex": "U[A-Z]", + "description": "" + }, + { + "name": "Letter Post Insured", + "matches_regex": "V[A-Z]", + "description": "" + }, + { + "name": "Parcel Post", + "matches_regex": "C[A-Z]", + "description": "" + }, + { + "name": "Parcel Post (e-commerce)", + "matches_regex": "H[A-Z]", + "description": "" + }, + { + "name": "Domestic", + "matches_regex": "([BDNPZ][A-Z]|A[V-Z]|G[AD])", + "description": "Mail designated for domestic, bilateral, or multilateral use" + } + ] + }, + { + "name": "Courier", + "regex_group_name": "CountryCode", + "lookup": [ + { + "matches": "AF", + "country": "Afghanistan", + "courier": "Afghan Post", + "courier_url": "http://postalcode.afghanpost.gov.af/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/afghanistan.html" + }, + { + "matches": "AL", + "country": "Albania", + "courier": "Posta Shqiptare", + "courier_url": "http://www.postashqiptare.al/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/albania.html" + }, + { + "matches": "DZ", + "country": "Algeria", + "courier": "Algérie Poste", + "courier_url": "http://www.poste.dz/codepostal/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/algeria.html" + }, + { + "matches": "AO", + "country": "Angola", + "courier": "Correios de Angola", + "courier_url": "http://www.correiosdeangola.co.ao/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/angola.html" + }, + { + "country": "Antigua and Barbuda", + "matches": "AG", + "courier": "Antigua Postal Services", + "courier_url": null, + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/antigua-and-barbuda.html" + }, + { + "matches": "AR", + "country": "Argentina", + "courier": "Correo Argentino", + "courier_url": "http://www.correoargentino.com.ar/formularios/cpa", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/argentina.html" + }, + { + "matches": "AM", + "country": "Armenia", + "courier": "Haypost - Armenian Postal Service", + "courier_url": "http://www.haypost.am/view-lang-eng-page-25.html", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/armenia.html" + }, + { + "matches": "AU", + "country": "Australia", + "courier": "Australia Post", + "courier_url": "http://www1.auspost.com.au/postcodes/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/australia.html" + }, + { + "matches": "AT", + "country": "Austria", + "courier": "Österreichische Post AG", + "courier_url": "http://www.post.at/en/index.php", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/austria.html" + }, + { + "matches": "AZ", + "country": "Azerbaijan", + "courier": "Azarpoçt", + "courier_url": "http://www.azerpost.az/?options=content&id=188&language=en", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/azerbaijan.html" + }, + { + "matches": "BS", + "country": "Bahamas", + "courier": "Bahamas Postal Service", + "courier_url": "http://www.bahamas.gov.bs/postalservice", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/bahamas.html" + }, + { + "matches": "BH", + "country": "Bahrain", + "courier": "Bahrain Post", + "courier_url": "http://www.transportation.gov.bh/en/modules.php?name=Content&pa=showpage&pid=97", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/bahrain.html" + }, + { + "matches": "BD", + "country": "Bangladesh", + "courier": "Bangladesh Post Office", + "courier_url": "http://www.bangladeshpost.gov.bd/PostCode.asp", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/bangladesh.html" + }, + { + "matches": "BB", + "country": "Barbados", + "courier": "Barbados Postal Service", + "courier_url": "http://www.bps.gov.bb/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/barbados.html" + }, + { + "matches": "BY", + "country": "Belarus", + "courier": "Belpochta", + "courier_url": "http://zip.belpost.by/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/belarus.html" + }, + { + "matches": "BE", + "country": "Belgium", + "courier": "bpost", + "courier_url": "http://www.bpost.be/site/fr/residential/customerservice/search/postal_codes.html", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/belgium.html" + }, + { + "matches": "BZ", + "country": "Belize", + "courier": "Belize Postal Service", + "courier_url": "http://www.belizepostalservice.gov.bz/site/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/belize.html" + }, + { + "matches": "BJ", + "country": "Benin", + "courier": "La Poste du Bénin", + "courier_url": "http://www.laposte.bj/index1.php?id_page=1", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/benin.html" + }, + { + "matches": "BT", + "country": "Bhutan", + "courier": "Bhutan Post", + "courier_url": "http://www.bhutanpost.com.bt/postcode/postcode.php", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/bhutan.html" + }, + { + "matches": "BO", + "country": "Bolivia", + "courier": "ECOBOL – Empresa de Correos de Bolivia", + "courier_url": "http://www.correosbolivia.com/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/bolivia.html" + }, + { + "matches": "BA", + "country": "Bosnia and Herzegovina", + "courier": "JP BH POŠTA d.o.o. Sarajevo", + "courier_url": "http://www.post.ba/postanski_brojevi_bih.php", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/bosnia-and-herzegovina.html" + }, + { + "matches": "BW", + "country": "Botswana", + "courier": "BotswanaPost", + "courier_url": "http://www.botspost.co.bw/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/botswana.html" + }, + { + "matches": "BR", + "country": "Brazil", + "courier": "CORREIOS", + "courier_url": "http://www.buscacep.correios.com.br/servicos/dnec/index.do", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/brazil.html" + }, + { + "matches": "BN", + "country": "Brunei Darussalam", + "courier": "Brunei Postal Services", + "courier_url": "http://www.post.gov.bn/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/brunei-darussalam.html" + }, + { + "matches": "BG", + "country": "Bulgaria (Rep.)", + "courier": "Bulgarian Posts", + "courier_url": "http://www.bgpost.bg/?cid=131", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/bulgaria-rep.html" + }, + { + "matches": "BF", + "country": "Burkina Faso", + "courier": "SONAPOST", + "courier_url": "http://www.sonapost.bf/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/burkina-faso.html" + }, + { + "matches": "BI", + "country": "Burundi", + "courier": "RNP – Régie nationale des postes", + "courier_url": "http://www.poste.bi/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/burundi.html" + }, + { + "matches": "KH", + "country": "Cambodia", + "courier": "Ministry of Posts and Telecommunications", + "courier_url": "http://www.mptc.gov.kh/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/cambodia.html" + }, + { + "matches": "CM", + "country": "Cameroon", + "courier": "CAMPOST – Cameroon Postal Services", + "courier_url": "http://campostonline.com/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/cameroon.html" + }, + { + "matches": "CA", + "country": "Canada", + "courier": "Canada Post", + "courier_url": "http://www.canadapost.ca/cpotools/apps/fpc/personal/findByCity?execution=e1s1", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/canada.html" + }, + { + "country": "Cape Verde", + "matches": "CV", + "courier": "Correios de Cabo Verde", + "courier_url": "http://www.correios.cv/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/cape-verde.html" + }, + { + "country": "Central African Rep.", + "matches": "CF", + "courier": "Direction des services postaux de l'Office National des Postes et de l'Épargne", + "courier_url": null, + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/central-african-rep.html" + }, + { + "matches": "TD", + "country": "Chad", + "courier": "Société tchadienne des postes et de l'épargne", + "courier_url": null, + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/chad.html" + }, + { + "matches": "CL", + "country": "Chile", + "courier": "Correos de Chile", + "courier_url": "http://www.correos.cl/SitePages/home.aspx", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/chile.html" + }, + { + "matches": "CN", + "country": "China (People's Rep.)", + "courier": "China Post", + "courier_url": "http://www.cpdc.com.cn/web/index.php?m=postsearch&c=index&a=init&t=addr", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/china-peoples-rep.html" + }, + { + "matches": "HK", + "country": "China", + "courier": "Hong Kong Post", + "courier_url": "http://www.hongkongpost.hk", + "upu_reference_url": "" + }, + { + "matches": "CO", + "country": "Colombia", + "courier": "4-72 La Red Postal de Colombia", + "courier_url": "http://visor.codigopostal.gov.co/472/visor/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/colombia.html" + }, + { + "matches": "KM", + "country": "Comoros", + "courier": "Societé Nationale des Postes et des Services Financiers", + "courier_url": "http://www.lapostecomores.com/bureaux.php", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/comoros.html" + }, + { + "matches": "CG", + "country": "Congo (Rep.)", + "courier": "Congolese Posts and Savings Company", + "courier_url": null, + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/congo-rep.html" + }, + { + "country": "Costa Rica", + "matches": "CR", + "courier": "Correos de Costa Rica", + "courier_url": "https://www.correos.go.cr/nosotros/codigopostal/busqueda.html", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/costa-rica.html" + }, + { + "matches": "HR", + "country": "Croatia", + "courier": "Hrvatska Posta - Croatian Post", + "courier_url": "http://www.posta.hr/default.aspx?pretpum&id=3417", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/croatia.html" + }, + { + "matches": "CU", + "country": "Cuba", + "courier": "Ministerio de la Informática y las comunicaciones de Cuba", + "courier_url": "http://www.mic.gov.cu/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/cuba.html" + }, + { + "matches": "CY", + "country": "Cyprus", + "courier": "Cyprus Post", + "courier_url": "http://www.mcw.gov.cy/mcw/dps/dps.nsf/index_en/index_en?OpenDocument", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/cyprus.html" + }, + { + "matches": "CZ", + "country": "Czech Rep.", + "courier": "Česká Pošta", + "courier_url": "http://psc.cpost.cz/CleanForm.action?request_locale=en", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/czech-rep.html" + }, + { + "matches": "CI", + "country": "Côte d'Ivoire (Rep.)", + "courier": "La Poste de Côte d’Ivoire", + "courier_url": "http://www.laposte.ci/bureau.php", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/cote-divoire-rep.html" + }, + { + "matches": "KP", + "country": "Dem People's Rep. of Korea", + "courier": "Korea Post and Telecommunications Corporation", + "courier_url": null, + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/dem-peoples-rep-of-korea.html" + }, + { + "matches": "CD", + "country": "Democratic Republic of the Congo", + "courier": "Congolese Posts and Telecommunications Corporation", + "courier_url": null, + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/democratic-republic-of-the-congo.html" + }, + { + "matches": "DK", + "country": "Denmark", + "courier": "Post Danmark", + "courier_url": "http://www.postdanmark.dk/en/find_postcode/Pages/home.aspx", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/denmark.html" + }, + { + "matches": "DJ", + "country": "Djibouti", + "courier": "La Poste de Djibouti", + "courier_url": null, + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/djibouti.html" + }, + { + "matches": "DM", + "country": "Dominica", + "courier": "General Post Office", + "courier_url": "http://publicworks.gov.dm/index.php/divisions/general-post-office/20-gpo-about-us", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/dominica.html" + }, + { + "matches": "DO", + "country": "Dominican Republic", + "courier": "INPOSDOM – Instituto Postal Dominicano", + "courier_url": "http://www.inposdom.gob.do/servicios/codigo-postal", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/dominican-republic.html" + }, + { + "matches": "EC", + "country": "Ecuador", + "courier": "Correos del Ecuador", + "courier_url": "http://www.codigopostal.gob.ec/#", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/ecuador.html" + }, + { + "matches": "EG", + "country": "Egypt", + "courier": "Egypt Post", + "courier_url": "http://www.egyptpost.org/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/egypt.html" + }, + { + "matches": "SV", + "country": "El Salvador", + "courier": "Correos de El Salvador", + "courier_url": "http://www.correos.gob.sv/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/el-salvador.html" + }, + { + "matches": "GQ", + "country": "Equatorial Guinea", + "courier": "Equatorial Guinea Post", + "courier_url": null, + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/equatorial-guinea.html" + }, + { + "matches": "ER", + "country": "Eritrea", + "courier": "Eritrean Postal Service", + "courier_url": "http://www.eriposta.com/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/eritrea.html" + }, + { + "matches": "EE", + "country": "Estonia", + "courier": "Eesti Post", + "courier_url": "http://www.post.ee/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/estonia.html" + }, + { + "matches": "ET", + "country": "Ethiopia", + "courier": "Ethiopian postal service", + "courier_url": "http://www.ethiopostal.com/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/ethiopia.html" + }, + { + "matches": "FJ", + "country": "Fiji", + "courier": "Post Fiji", + "courier_url": "http://www.postfiji.com.fj/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/fiji.html" + }, + { + "matches": "FI", + "country": "Finland (including the Åland Islands)", + "courier": "Posti Ltd", + "courier_url": "http://www.verkkoposti.com/e3/english/postalcodecatalog", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/finland-including-the-aaland-islands.html" + }, + { + "matches": "FR", + "country": "France", + "courier": "La Poste", + "courier_url": "http://www.laposte.fr/Entreprise/Outils-Indispensables/Outils/Trouvez-un-code-postal", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/france.html" + }, + { + "matches": "GA", + "country": "Gabon", + "courier": "La Poste SA", + "courier_url": "http://www.laposte.ga/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/gabon.html" + }, + { + "matches": "GM", + "country": "Gambia", + "courier": "Gambia Postal services Corporation", + "courier_url": "http://www.gampost.gm/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/gambia.html" + }, + { + "matches": "GE", + "country": "Georgia", + "courier": "Georgian Post", + "courier_url": "http://www.georgianpost.ge/?site-lang=en&site-path=help/zipcodes/&letter=A", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/georgia.html" + }, + { + "matches": "DE", + "country": "Germany", + "courier": "Deutsche Post", + "courier_url": "http://www.postdirekt.de/plzserver/PlzSearchServlet?lang=en_GB&id=viewstreet", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/germany.html" + }, + { + "matches": "GH", + "country": "Ghana", + "courier": "Ghana Post", + "courier_url": "http://www.ghanapostgh.com/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/ghana.html" + }, + { + "country": "Great Britain", + "matches": "GB", + "courier": "Royal Mail Group plc", + "courier_url": "http://www.royalmail.com/postcode-finder?gear=postcode&campaignid=postcodefinder_redirect", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/great-britain.html" + }, + { + "matches": "GR", + "country": "Greece", + "courier": "Hellenic Post ELTA", + "courier_url": "http://www.elta.gr/en-us/findapostcode.aspx", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/greece.html" + }, + { + "matches": "GD", + "country": "Grenada", + "courier": "Grenada Postal Corporation", + "courier_url": "http://www.grenadapostal.com/index.html", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/grenada.html" + }, + { + "matches": "GT", + "country": "Guatemala", + "courier": "El Correo", + "courier_url": "http://www.elcorreo.com.gt/cdgcorreo/index.php?option=com_content&view=article&id=104&Itemid=233", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/guatemala.html" + }, + { + "matches": "GN", + "country": "Guinea", + "courier": "Office de la poste guinéenne", + "courier_url": null, + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/guinea.html" + }, + { + "country": "Guinea-Bissau", + "matches": "GW", + "courier": "Correios da Guiné-Bissau", + "courier_url": null, + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/guinea-bissau.html" + }, + { + "matches": "GY", + "country": "Guyana", + "courier": "Guyana Post Office Corporation", + "courier_url": "http://guypost.gy/gpoc/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/guyana.html" + }, + { + "matches": "HT", + "country": "Haiti", + "courier": "Office des Postes d’Haiti", + "courier_url": "http://postehaiti.gouv.ht/notre-reseau-postal", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/haiti.html" + }, + { + "matches": "HN", + "country": "Honduras (Rep.)", + "courier": "Honducor", + "courier_url": "http://honducor.gob.hn/codpost/consulta.php", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/honduras-rep.html" + }, + { + "matches": "HU", + "country": "Hungary", + "courier": "Magyar Posta", + "courier_url": "http://www.posta.hu/ugyfelszolgalat/iranyitoszam_kereso", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/hungary.html" + }, + { + "matches": "IS", + "country": "Iceland", + "courier": "Íslandspóstur hf", + "courier_url": "http://www.postur.is/en/desktopdefault.aspx/tabid-450/700_read-1715/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/iceland.html" + }, + { + "matches": "IN", + "country": "India", + "courier": "India Post", + "courier_url": "http://www.indiapost.gov.in/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/india.html" + }, + { + "matches": "ID", + "country": "Indonesia", + "courier": "Pos Indonesia", + "courier_url": "http://kodepos.indonesiaweb.info/en/street/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/indonesia.html" + }, + { + "matches": "IR", + "country": "Iran (Islamic Rep.)", + "courier": "Islamic Republic of Iran Post Co.", + "courier_url": "http://www.post.ir/Homepage.aspx?site=PostPortal&lang=fa-IR&tabid=0", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/iran-islamic-rep.html" + }, + { + "matches": "IQ", + "country": "Iraq", + "courier": "Iraqi Post", + "courier_url": "http://www.iraqipost.net/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/iraq.html" + }, + { + "matches": "IE", + "country": "Ireland", + "courier": "AN Post - regulatory and International affairs Unit", + "courier_url": "http://locator.anpost.ie/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/ireland.html" + }, + { + "matches": "IL", + "country": "Israel", + "courier": "Israel Post", + "courier_url": "http://www.israelpost.co.il/zipcode.nsf/demozip?openform", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/israel.html" + }, + { + "matches": "IT", + "country": "Italy", + "courier": "Poste Italiane", + "courier_url": "http://www.poste.it/online/cercacap/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/italy.html" + }, + { + "matches": "JM", + "country": "Jamaica", + "courier": "Jamaica Post", + "courier_url": "http://www.jamaicapost.gov.jm/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/jamaica.html" + }, + { + "matches": "JP", + "country": "Japan", + "courier": "Japan Post", + "courier_url": "http://www.post.japanpost.jp/zipcode/index.html", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/japan.html" + }, + { + "matches": "JO", + "country": "Jordan", + "courier": "Jordan Post", + "courier_url": "http://www.jordanpost.com.jo/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/jordan.html" + }, + { + "matches": "KZ", + "country": "Kazakhstan", + "courier": "Kazpost", + "courier_url": "http://www.kazpost.kz/ru/poisk-pochtovogo-indeksa-0", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/kazakhstan.html" + }, + { + "matches": "KE", + "country": "Kenya", + "courier": "Posta Kenya", + "courier_url": "http://www.posta.co.ke/postOfficeFind.asp", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/kenya.html" + }, + { + "matches": "KI", + "country": "Kiribati", + "courier": "Kiribati Public Service Public", + "courier_url": null, + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/kiribati.html" + }, + { + "matches": "KR", + "country": "Korea (Rep.)", + "courier": "Korea Post", + "courier_url": "http://www.epost.go.kr/roadAreaCdEng.retrieveRdEngAreaCdList.comm", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/korea-rep.html" + }, + { + "matches": "KW", + "country": "Kuwait", + "courier": "Kuwait Ministry of Communications", + "courier_url": "http://moc.kw/English/index.html", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/kuwait.html" + }, + { + "matches": "KG", + "country": "Kyrgyzstan", + "courier": "Kyrgyz Post", + "courier_url": "http://kyrgyzpost.kg/ru/news.html", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/kyrgyzstan.html" + }, + { + "matches": "LA", + "country": "Laos", + "courier": "Entreprise des Postes Lao", + "courier_url": null, + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/lao-peoples-dem-rep.html" + }, + { + "matches": "LV", + "country": "Latvia", + "courier": "Latvia Post", + "courier_url": "http://www.pasts.lv/lv/uzzinas/parbaudit-adresi/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/latvia.html" + }, + { + "matches": "LB", + "country": "Lebanon", + "courier": "LibanPost", + "courier_url": "http://www.libanpost.com.lb/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/lebanon.html" + }, + { + "matches": "LS", + "country": "Lesotho", + "courier": "Lesotho Post", + "courier_url": "http://lesothopost.org.ls/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/lesotho.html" + }, + { + "matches": "LR", + "country": "Liberia", + "courier": "Ministry of Posts and Telecommunications", + "courier_url": "http://www.mopt.gov.lr/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/liberia.html" + }, + { + "matches": "LY", + "country": "Libya", + "courier": "Libya Post", + "courier_url": "http://libyapost.ly/en/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/libya.html" + }, + { + "matches": "LI", + "country": "Liechtenstein", + "courier": "Liechtensteinische Post AG", + "courier_url": "http://www.post.li/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/liechtenstein.html" + }, + { + "matches": "LT", + "country": "Lithuania", + "courier": "Lietuvos Pastas", + "courier_url": "http://www.post.lt/en/help/postal-code-search", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/lithuania.html" + }, + { + "matches": "LU", + "country": "Luxembourg", + "courier": "Post", + "courier_url": "http://www.post.lu/en/particuliers/courrier/rechercher-un-code-postal", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/luxembourg.html" + }, + { + "matches": "MG", + "country": "Madagascar", + "courier": "PAOSITRA MALAGASY", + "courier_url": "http://www.mtpc.gov.mg/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/madagascar.html" + }, + { + "matches": "MW", + "country": "Malawi", + "courier": "Malawi Posts Corporation", + "courier_url": "http://www.malawiposts.com/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/malawi.html" + }, + { + "matches": "MY", + "country": "Malaysia", + "courier": "Pos Malaysia", + "courier_url": "http://www.pos.com.my/pos/homepage.aspx", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/malaysia.html" + }, + { + "matches": "MV", + "country": "Maldives", + "courier": "Maldives Post", + "courier_url": "http://www.maldivespost.com/index.php?lid=10", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/maldives.html" + }, + { + "matches": "ML", + "country": "Mali", + "courier": "Office national des postes", + "courier_url": "http://www.laposte.ml/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/mali.html" + }, + { + "matches": "MT", + "country": "Malta", + "courier": "Malta Post", + "courier_url": "http://postcodes.maltapost.com/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/malta.html" + }, + { + "matches": "MR", + "country": "Mauritania", + "courier": "MAURIPOST – Société Mauritanienne des Postes", + "courier_url": "http://www.mauripost.mr/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/mauritania.html" + }, + { + "matches": "MU", + "country": "Mauritius", + "courier": "Mauritius Post", + "courier_url": "http://www.mauritiuspost.mu/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/mauritius.html" + }, + { + "matches": "MX", + "country": "Mexico", + "courier": "Correos de México", + "courier_url": "http://www.correosdemexico.gob.mx/ServiciosLinea/Paginas/ccpostales.aspx", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/mexico.html" + }, + { + "matches": "MD", + "country": "Moldova", + "courier": "Posta Moldovei", + "courier_url": "http://www.posta.md/ro/postal_code.html", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/moldova.html" + }, + { + "matches": "MC", + "country": "Monaco", + "courier": "La Poste Monaco", + "courier_url": "http://www.lapostemonaco.mc/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/monaco.html" + }, + { + "matches": "MN", + "country": "Mongolia", + "courier": "Mongol Post - Монгол шуудан компани", + "courier_url": "http://www.zipcode.mn/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/mongolia.html" + }, + { + "country": "Montenegro (Rep.)", + "matches": "ME", + "courier": "Pošta Crne Gore", + "courier_url": "http://www.postacg.me/main.php?idstr=177", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/montenegro-rep.html" + }, + { + "matches": "MA", + "country": "Morocco", + "courier": "Barid Al-Maghrib – Poste Maroc", + "courier_url": "http://www.codepostal.ma/search_mot.aspx?keyword=", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/morocco.html" + }, + { + "matches": "MZ", + "country": "Mozambique", + "courier": "Correios de Moçambique", + "courier_url": "http://www.correios.co.mz/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/mozambique.html" + }, + { + "matches": "MM", + "country": "Myanmar", + "courier": "Myanmar Post and Telecommunications Department", + "courier_url": null, + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/myanmar.html" + }, + { + "matches": "NA", + "country": "Namibia", + "courier": "NAM Post", + "courier_url": "https://www.nampost.com.na/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/namibia.html" + }, + { + "matches": "NR", + "country": "Nauru", + "courier": "Nauru General Post Office", + "courier_url": null, + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/nauru.html" + }, + { + "matches": "NP", + "country": "Nepal", + "courier": "Nepal Postal Services", + "courier_url": "http://www.gpo.gov.np/postalcode.aspx", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/nepal.html" + }, + { + "matches": "NL", + "country": "Netherlands", + "courier": "PostNL", + "courier_url": "http://www.postnl.nl/voorthuis/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/netherlands.html" + }, + { + "matches": "NZ", + "country": "New Zealand (including the Ross Dependency)", + "courier": "New Zealand Post", + "courier_url": "http://www.nzpost.co.nz/Cultures/en-NZ/OnlineTools/PostCodeFinder/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/new-zealand-including-the-ross-dependency.html" + }, + { + "matches": "NI", + "country": "Nicaragua", + "courier": "Correos de Nicaragua", + "courier_url": "http://www.correos.gob.ni/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/nicaragua.html" + }, + { + "matches": "NE", + "country": "Niger", + "courier": "Niger Poste", + "courier_url": "http://www.nigerposte.net/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/niger.html" + }, + { + "matches": "NG", + "country": "Nigeria", + "courier": "Nigerian Postal Service", + "courier_url": "http://www.nigeriapostcodes.com/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/nigeria.html" + }, + { + "matches": "NO", + "country": "Norway", + "courier": "Posten", + "courier_url": "http://adressesok.posten.no/en/postal_codes/search", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/norway.html" + }, + { + "matches": "OM", + "country": "Oman", + "courier": "Oman Post", + "courier_url": "http://www.omanpost.om/Portals/2/Skins/skins//tabid/64/Default.aspx", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/oman.html" + }, + { + "matches": "PK", + "country": "Pakistan", + "courier": "Pakistan Post", + "courier_url": "http://www.pakpost.gov.pk/postcode/postcode.html", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/pakistan.html" + }, + { + "matches": "PA", + "country": "Panama (Rep.)", + "courier": "Correos de Panamá", + "courier_url": "http://www.correospanama.gob.pa/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/panama-rep.html" + }, + { + "matches": "PG", + "country": "Papua New Guinea", + "courier": "Post PNG", + "courier_url": "http://www.postpng.com.pg/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/papua-new-guinea.html" + }, + { + "matches": "PY", + "country": "Paraguay", + "courier": "Correo Paraguayo", + "courier_url": "http://www.correoparaguayo.gov.py/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/paraguay.html" + }, + { + "matches": "PE", + "country": "Peru", + "courier": "SERPOST – Servicios Postales del Perú", + "courier_url": "http://www.mtc.gob.pe/portal/CPOSTAL/Listado_codigo_postal.html", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/peru.html" + }, + { + "matches": "PH", + "country": "Philippines", + "courier": "PHLPOST – Philippine Postal Corporation", + "courier_url": "https://www.phlpost.gov.ph/zip-code-search.php", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/philippines.html" + }, + { + "matches": "PL", + "country": "Poland", + "courier": "Poczta Polska", + "courier_url": "http://kody.poczta-polska.pl/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/poland.html" + }, + { + "matches": "PT", + "country": "Portugal", + "courier": "CTT - Correios", + "courier_url": "http://www.ctt.pt/feapl_2/app/open/tools.jspx?tool=1", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/portugal.html" + }, + { + "matches": "QA", + "country": "Qatar", + "courier": "Qatar Post", + "courier_url": "http://www.qpost.com.qa/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/qatar.html" + }, + { + "matches": "RO", + "country": "Romania", + "courier": "Posta Romana", + "courier_url": "http://www.posta-romana.ro/postal_codes", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/romania.html" + }, + { + "matches": "RU", + "country": "Russian Federation", + "courier": "Russian Post", + "courier_url": "http://www.russianpost.ru/rp/servise/ru/home/postuslug/searchops", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/russian-federation.html" + }, + { + "matches": "RW", + "country": "Rwanda", + "courier": "National Post Office (Iposita)", + "courier_url": "http://i-posita.rw/spip.php?article1", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/rwanda.html" + }, + { + "matches": "KN", + "country": "Saint Christopher (Saint Kitts) and Nevis", + "courier": "St. Kitts & Nevis Postal Services", + "courier_url": "http://www.post.gov.kn/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/saint-christopher-saint-kitts-and-nevis.html" + }, + { + "matches": "LC", + "country": "Saint Lucia", + "courier": "Saint Lucia Postal Service", + "courier_url": "http://www.stluciapostal.com/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/saint-lucia.html" + }, + { + "matches": "VC", + "country": "Saint Vincent and the Grenadines", + "courier": "SVG Postal Corporation", + "courier_url": "http://www.svgpost.gov.vc/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/saint-vincent-and-the-grenadines.html" + }, + { + "matches": "WS", + "country": "Samoa", + "courier": "Samoa Post", + "courier_url": "http://www.samoapost.ws/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/samoa.html" + }, + { + "matches": "SM", + "country": "San Marino", + "courier": "Poste San Marino", + "courier_url": "http://www.poste.sm/on-line/home.html", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/san-marino.html" + }, + { + "matches": "ST", + "country": "Sao Tome and Principe", + "courier": "Correios de São Tomé e Príncipe", + "courier_url": "http://www.inh.st/correios.st.htm", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/sao-tome-and-principe.html" + }, + { + "matches": "SA", + "country": "Saudi Arabia", + "courier": "Saudi Post", + "courier_url": "http://maps.address.gov.sa", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/saudi-arabia.html" + }, + { + "matches": "SN", + "country": "Senegal", + "courier": "La Poste Senegal", + "courier_url": "http://www.laposte.sn/laposte/trouver_codepostal.php", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/senegal.html" + }, + { + "matches": "RS", + "country": "Serbia (Rep.)", + "courier": "PTT Communications \"Srbija\"", + "courier_url": "http://www.posta.rs/struktura/eng/aplikacije/pronadji/nadji-pak-rezultat.asp", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/serbia-rep.html" + }, + { + "matches": "SC", + "country": "Seychelles", + "courier": "Seychelles Postal Service", + "courier_url": "http://www.seychellespost.gov.sc/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/seychelles.html" + }, + { + "matches": "SL", + "country": "Sierra Leone", + "courier": "Sierra Leone Postal Services", + "courier_url": "http://www.salpost.sl/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/sierra-leone.html" + }, + { + "matches": "SG", + "country": "Singapore", + "courier": "SingPost", + "courier_url": "http://www.singpost.com.sg/quick_services/index.htm", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/singapore.html" + }, + { + "matches": "SK", + "country": "Slovakia", + "courier": "Slovenská Posta", + "courier_url": "http://psc.posta.sk/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/slovakia.html" + }, + { + "matches": "SI", + "country": "Slovenia", + "courier": "Posta Slovenije d.o.o.", + "courier_url": "http://www.posta.si/postne-stevilke-doma", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/slovenia.html" + }, + { + "matches": "SB", + "country": "Solomon Islands", + "courier": "Solomon Post", + "courier_url": null, + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/solomon-islands.html" + }, + { + "matches": "SO", + "country": "Somalia", + "courier": "Somali Post", + "courier_url": null, + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/somalia.html" + }, + { + "matches": "ZA", + "country": "South Africa", + "courier": "South African Post Office", + "courier_url": "http://www.postoffice.co.za/ContactUs/postalcode.html", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/south-africa.html" + }, + { + "matches": "SS", + "country": "South Sudan (Rep.)", + "courier": "Minister of Telecommunication and Postal Services", + "courier_url": null, + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/south-sudan-rep.html" + }, + { + "matches": "ES", + "country": "Spain", + "courier": "Correos y Telégrafos", + "courier_url": "http://www.correos.es/ss/Satellite/site/pagina-buscador_codigos_postales/sidioma=es_ES", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/spain.html" + }, + { + "matches": "LK", + "country": "Sri Lanka", + "courier": "Sri Lanka Post", + "courier_url": "http://www.slpost.gov.lk/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/sri-lanka.html" + }, + { + "matches": "SD", + "country": "Sudan", + "courier": "Sudapost", + "courier_url": "http://sudapost.sd/index.php/en/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/sudan.html" + }, + { + "matches": "SR", + "country": "Suriname", + "courier": "SURPOST", + "courier_url": "http://www.surpost.com/surpost2/index.php", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/suriname.html" + }, + { + "matches": "SZ", + "country": "Swaziland", + "courier": "Swaziland Posts & Telecommunications Corporation", + "courier_url": "http://www.sptc.co.sz/swazipost/codes.php", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/swaziland.html" + }, + { + "matches": "SE", + "country": "Sweden", + "courier": "Posten Sweden Post", + "courier_url": "http://www.posten.se/oldurls/old_postnummersok.jspv", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/sweden.html" + }, + { + "matches": "CH", + "country": "Switzerland", + "courier": "La Poste Suisse", + "courier_url": "http://www.swisspost.ch/post-startseite.htm", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/switzerland.html" + }, + { + "matches": "SY", + "country": "Syrian Arab Rep.", + "courier": "Syrian Post", + "courier_url": "http://www.syrianpost.gov.sy/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/syrian-arab-rep.html" + }, + { + "matches": "TJ", + "country": "Tajikistan", + "courier": "Tajikistan’s communications service agency", + "courier_url": null, + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/tajikistan.html" + }, + { + "matches": "TZ", + "country": "Tanzania (United Rep.)", + "courier": "Tanzania Posts Corporation", + "courier_url": "http://www.posta.co.tz/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/tanzania-united-rep.html" + }, + { + "matches": "TH", + "country": "Thailand", + "courier": "Thailand Post", + "courier_url": "http://www.thailandpost.com/search.php", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/thailand.html" + }, + { + "matches": "MK", + "country": "The former Yugoslav Republic of Macedonia", + "courier": "Macedonian Post & Telecommunications", + "courier_url": "http://www.posta.mk/pravilno_adresiranje.html", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/the-former-yugoslav-republic-of-macedonia.html" + }, + { + "matches": "TL", + "country": "Timor-Leste (Dem. Rep.)", + "courier": "Correios de Timor Leste", + "courier_url": null, + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/timor-leste-dem-rep.html" + }, + { + "matches": "TG", + "country": "Togo", + "courier": "La Poste du Togo", + "courier_url": "http://www.laposte.tg/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/togo.html" + }, + { + "matches": "TO", + "country": "Tonga (including Niuafo'ou)", + "courier": "Tonga Post", + "courier_url": "http://tongapost.to/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/tonga-including-niuafoou.html" + }, + { + "matches": "TT", + "country": "Trinidad and Tobago", + "courier": "Trinidad and Tobago Postal Corporation", + "courier_url": "http://www.ttpost.net/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/trinidad-and-tobago.html" + }, + { + "matches": "TN", + "country": "Tunisia", + "courier": "La Poste Tunisienne", + "courier_url": "http://www.poste.tn/codes.php", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/tunisia.html" + }, + { + "matches": "TR", + "country": "Turkey", + "courier": "Turkey Post", + "courier_url": "http://postakodu.ptt.gov.tr/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/turkey.html" + }, + { + "matches": "TM", + "country": "Turkmenistan", + "courier": "Turkmenpost", + "courier_url": "http://www.turkmenpost.gov.tm/about_index.php", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/turkmenistan.html" + }, + { + "matches": "TV", + "country": "Tuvalu", + "courier": "Tuvalu Philatelic Bureau", + "courier_url": null, + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/tuvalu.html" + }, + { + "matches": "UG", + "country": "Uganda", + "courier": "Posta Uganda", + "courier_url": "http://www.ugapost.co.ug/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/uganda.html" + }, + { + "matches": "UA", + "country": "Ukraine", + "courier": "Ukrposhta", + "courier_url": "http://services.ukrposhta.com/postindex_new/default.aspx?lang=en", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/ukraine.html" + }, + { + "matches": "AE", + "country": "United Arab Emirates", + "courier": "Emirates Post", + "courier_url": "http://www.emiratespost.com/content/english/index.jsp;jsessionid=35fbe352a6c441a491929d81d54fa0c6", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/united-arab-emirates.html" + }, + { + "matches": "US", + "country": "United States of America", + "courier": "United States Postal Service", + "courier_url": "http://zip4.usps.com/zip4/welcome.jsp", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/united-states-of-america.html" + }, + { + "matches": "UY", + "country": "Uruguay", + "courier": "Correo Uruguayo", + "courier_url": "http://www.correo.com.uy/index.asp?codPag=codPost&switchMapa=codPost", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/uruguay.html" + }, + { + "matches": "UZ", + "country": "Uzbekistan", + "courier": "Post of Uzbekistan", + "courier_url": "http://www.pochta.uz/index.php/en/postal-indexes/9", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/eastern-europe-and-northern-asia/uzbekistan.html" + }, + { + "matches": "VU", + "country": "Vanuatu", + "courier": "Vanuatu Post", + "courier_url": "http://www.vanuatupost.vu/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/vanuatu.html" + }, + { + "matches": "VA", + "country": "Vatican", + "courier": "Vatican post", + "courier_url": "http://www.vaticanstate.va/EN/Services/Philatelic_and_Numismatic_Office/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/western-europe/vatican.html" + }, + { + "matches": "VE", + "country": "Venezuela", + "courier": "IPOSTEL – Instituto Postal Telegráfico de Venezuela", + "courier_url": "http://www.ipostel.gob.ve/nlinea/codigo_postal.php", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/americas/venezuela.html" + }, + { + "matches": "VN", + "country": "Viet Nam", + "courier": "VNPT – Vietnam Posts and Telecommunications Group", + "courier_url": "http://postcode.vnpost.vn/services/search.aspx", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/viet-nam.html" + }, + { + "matches": "YE", + "country": "Yemen", + "courier": "Yemen Post", + "courier_url": "http://www.post.ye/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/southern-asia-and-oceania/yemen.html" + }, + { + "matches": "ZM", + "country": "Zambia", + "courier": "Zambia Postal Services Corporation (ZAMPOST)", + "courier_url": "http://www.zampost.com.zm/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/zambia.html" + }, + { + "matches": "ZW", + "country": "Zimbabwe", + "courier": "Zimpost – Zimbabwe Posts", + "courier_url": "http://www.zimpost.co.zw/", + "upu_reference_url": "http://www.upu.int/en/the-upu/member-countries/africa/zimbabwe.html" + } + ] + } + ] + } + ] +} diff --git a/custom_components/parcelapp/tracking_data/ups.json b/custom_components/parcelapp/tracking_data/ups.json new file mode 100644 index 0000000..2804214 --- /dev/null +++ b/custom_components/parcelapp/tracking_data/ups.json @@ -0,0 +1,193 @@ +{ + "name": "UPS", + "courier_code": "ups", + "tracking_numbers": [ + { + "name": "UPS", + "id": "ups", + "regex": [ + "\\s*1\\s*Z\\s*(?", + "(?(?:[A-Z0-9]\\s*){6,6})", + "(?(?:[A-Z0-9]\\s*){2,2})", + "(?(?:[A-Z0-9]\\s*){7,7}))", + "(?[0-9]\\s*)" + ], + "validation": { + "checksum": { + "name": "mod10", + "evens_multiplier": 1, + "odds_multiplier": 2 + } + }, + "tracking_url": "https://wwwapps.ups.com/WebTracking/track?track=yes&trackNums=%s", + "additional": [ + { + "name": "Service Type", + "regex_group_name": "ServiceType", + "lookup": [ + { + "matches": "01", + "name": "UPS United States Next Day Air (Red)" + }, + { + "matches": "02", + "name": "UPS United States Second Day Air (Blue)" + }, + { + "matches": "03", + "name": "UPS United States Ground" + }, + { + "matches": "12", + "name": "UPS United States Third Day Select" + }, + { + "matches": "13", + "name": "UPS United States Next Day Air Saver (Red Saver)" + }, + { + "matches": "15", + "name": "UPS United States Next Day Air Early A.M." + }, + { + "matches": "22", + "name": "UPS United States Ground - Returns Plus - Three Pickup Attempts" + }, + { + "matches": "32", + "name": "UPS United States Next Day Air Early A.M. - COD" + }, + { + "matches": "33", + "name": "UPS United States Next Day Air Early A.M. - Saturday Delivery, COD" + }, + { + "matches": "41", + "name": "UPS United States Next Day Air Early A.M. - Saturday Delivery" + }, + { + "matches": "42", + "name": "UPS United States Ground - Signature Required" + }, + { + "matches": "44", + "name": "UPS United States Next Day Air - Saturday Delivery" + }, + { + "matches": "66", + "name": "UPS United States Worldwide Express" + }, + { + "matches": "72", + "name": "UPS United States Ground - Collect on Delivery" + }, + { + "matches": "78", + "name": "UPS United States Ground - Returns Plus - One Pickup Attempt" + }, + { + "matches": "90", + "name": "UPS United States Ground - Returns - UPS Prints and Mails Label" + }, + { + "matches": "A0", + "name": "UPS United States Next Day Air Early A.M. - Adult Signature Required" + }, + { + "matches": "A1", + "name": "UPS United States Next Day Air Early A.M. - Saturday Delivery, Adult Signature Required" + }, + { + "matches": "A2", + "name": "UPS United States Next Day Air - Adult Signature Required" + }, + { + "matches": "A8", + "name": "UPS United States Ground - Adult Signature Required" + }, + { + "matches": "A9", + "name": "UPS United States Next Day Air Early A.M. - Adult Signature Required, COD" + }, + { + "matches": "AA", + "name": "UPS United States Next Day Air Early A.M. - Saturday Delivery, Adult Signature Required, COD" + }, + { + "matches": "YW", + "name": "UPS SurePost - Delivered by the USPS" + } + ] + } + ], + "test_numbers": { + "valid": [ + "1Z5R89390357567127", + "1Z879E930346834440", + "1Z410E7W0392751591", + "1Z8V92A70367203024", + " 1 Z 8 V 9 2 A 7 0 3 6 7 2 0 3 0 2 4 ", + "1ZXX3150YW44070023" + ], + "invalid": [ + "2Z5R89390357567127", + "1A5R89390357567127", + "1Z1111111111111111" + ] + } + }, + { + "name": "UPS Waybill", + "regex": [ + "\\s*(?([AHJKTV]\\s*){1})", + "(?(?:[0-9]\\s*){9})", + "(?[0-9]\\s*){1}" + ], + "validation": { + "checksum": { + "name": "mod10", + "evens_multiplier": 1, + "odds_multiplier": 2 + } + }, + "additional": [ + { + "name": "Service Type", + "regex_group_name": "ServiceType", + "lookup": [ + { + "matches": "J", + "name": "UPS Next Day Express" + }, + { + "matches": "K", + "name": "UPS Ground" + }, + { + "matches": "V", + "name": "UPS WorldWide Express Saver" + } + ] + } + ], + "tracking_url": "https://wwwapps.ups.com/WebTracking/track?track=yes&trackNums=%s", + "test_numbers": { + "valid": [ + "K1506235620", + "K 150 623 562 0", + "K2479825491", + "J4603636537", + "V0490119172", + "V0431105627" + ], + "invalid": [ + "K1506235622", + "K2479825492", + "J4603636538", + "V0411335627", + "V0423305841" + ] + } + } + ] +} diff --git a/custom_components/parcelapp/tracking_data/usps.json b/custom_components/parcelapp/tracking_data/usps.json new file mode 100644 index 0000000..b2c4e32 --- /dev/null +++ b/custom_components/parcelapp/tracking_data/usps.json @@ -0,0 +1,271 @@ +{ + "name": "United States Postal Service", + "courier_code": "usps", + "tracking_numbers": [ + { + "name": "USPS 20", + "id": "usps_20", + "description": "20 digit USPS numbers", + "tracking_url": "https://tools.usps.com/go/TrackConfirmAction?tLabels=%s", + "regex": [ + "\\s*(?", + "(?([0-9]\\s*){2})", + "(?([0-9]\\s*){9})", + "(?([0-9]\\s*){8})", + ")", + "(?[0-9]\\s*)" + ], + "validation": { + "checksum": { + "name": "mod10", + "evens_multiplier": 3, + "odds_multiplier": 1 + } + }, + "test_numbers": { + "valid": [ + "0307 1790 0005 2348 3741", + " 0 3 0 7 1 7 9 0 0 0 0 5 2 3 4 8 3 7 4 1 ", + "7112 3456 7891 2345 6787" + ], + "invalid": [ + "0307 1790 0005 2348 3742" + ] + }, + "additional": [ + { + "name": "Service Type", + "regex_group_name": "ServiceType", + "lookup": [ + { + "matches": "71", + "name": "Certified Mail" + }, + { + "matches": "73", + "name": "Insured Mail" + }, + { + "matches": "77", + "name": "Registered Mail" + }, + { + "matches": "81", + "name": "Return Receipt For Merchanise" + } + ] + } + ] + }, + { + "name": "USPS 22", + "id": "usps_22", + "description": "22 digit USPS numbers", + "tracking_url": "https://tools.usps.com/go/TrackConfirmAction?tLabels=%s", + "regex": [ + "\\s*((?4\\s*2\\s*0\\s*)(?([0-9]\\s*){5}))?", + "(?", + "(?(9\\s*[4-8]\\s*))", + "(?([0-9]\\s*){9})", + "(?([0-9]\\s*){10})", + ")", + "(?[0-9]\\s*)" + ], + "validation": { + "checksum": { + "name": "mod10", + "evens_multiplier": 3, + "odds_multiplier": 1, + "reverse": true + } + }, + "test_numbers": { + "valid": [ + "420787459400111206206406260787", + "9400111206206406260787", + "9434611206206406227577", + "9434611206206407667136", + "9400111206206407628746", + "9400111206206407628845" + ], + "invalid": [ + "9434611206206407667131", + "9434611306206407667222", + "2334611306206407667222" + ] + }, + "partners": [{ + "description": "DHL ECommerce uses USPS for last mile delivery", + "partner_type": "shipper", + "partner_id": "dhl_ecommerce_30", + "validation": { + "matches_all": [ + { + "regex_group_name": "RoutingApplicationId", + "matches": "420" + }, + { + "matches_regex": "9[24]", + "regex_group_name": "ServiceType" + } + ] + } + }], + "additional": [ + { + "name": "Service Type", + "regex_group_name": "ServiceType", + "lookup": [ + { "matches": "92", "name": "Signature Confirmation" }, + { "matches": "93", "name": "Collect on Delivery (COD)" }, + { "matches": "94", "name": "USPS Tracking" }, + { "matches": "95", "name": "Certified Mail" }, + { "matches": "96", "name": "Insured Mail" }, + { "matches": "97", "name": "Registered Mail" }, + { "matches": "98", "name": "Return Receipt for Merchandise" } + ] + } + ] + }, + { + "name": "USPS 34v2", + "id": "usps_32V2", + "description": "variation on 34 digit USPS IMpd numbers", + "regex": [ + "\\s*(?4\\s*2\\s*0\\s*)(?([0-9]\\s*){5})", + "(?([0-9]\\s*){4})", + "(?", + "(?9\\s*[2345]\\s*)?", + "(?([0-9]\\s*){8})", + "(?([0-9]\\s*){11})", + ")", + "(?[0-9]\\s*)" + ], + "validation": { + "checksum": { + "name": "mod10", + "evens_multiplier": 3, + "odds_multiplier": 1 + } + }, + "tracking_url": "https://tools.usps.com/go/TrackConfirmAction?tLabels=%s", + "test_numbers": { + "valid": [ + "4201002334249200190132607600833457", + "4201028200009261290113185417468510", + " 4 2 0 1 0 2 8 2 0 0 0 0 9 2 6 1 2 9 0 1 1 3 1 8 5 4 1 7 4 6 8 5 1 0 " + ], + "invalid": [ + "4201028200009261290113185417468511" + ] + } + }, + { + "name": "USPS 91", + "id": "usps_91", + "description": "USPS now calls this the IMpd barcode format", + "regex": [ + "\\s*(?:(?4\\s*2\\s*0\\s*)(?([0-9]\\s*){5}))?", + "(?", + "(?9\\s*[12345]\\s*)?", + "(?([0-9]\\s*){2})", + "(?([0-9]\\s*){2})", + "(?([0-9]\\s*){8})", + "(?([0-9]\\s*){11}|([0-9]\\s*){7})", + ")", + "(?[0-9]\\s*)" + ], + "validation": { + "checksum": { + "name": "mod10", + "evens_multiplier": 3, + "odds_multiplier": 1 + }, + "serial_number_format": { + "prepend_if": { + "matches_regex": "^(?!9[1-5]).+", + "content": "91" + } + } + }, + "partners": [{ + "description": "FedEx SmartPost uses USPS for last mile delivery, but not all USPS91 numbers are SmartPosts", + "partner_type": "shipper", + "partner_id": "fedex_smartpost", + "validation": { + "matches_all": [ + { + "regex_group_name": "ServiceType", + "matches": "29" + }, + { + "matches": "61", + "regex_group_name": "SCNC" + }, + { + "regex_group_name": "ApplicationIdentifier", + "matches": "92" + } + ] + } + }, + { + "description": "DHL uses USPS for last mile delivery, but not all USPS91 numbers are DHL Ecommerce", + "partner_type": "shipper", + "partner_id": "dhl_ecommerce_30", + "validation": { + "matches_all": [ + { + "regex_group_name": "ServiceType", + "matches": "29" + }, + { + "matches": "61", + "regex_group_name": "SCNC" + }, + { + "matches": "420", + "regex_group_name": "RoutingApplicationId" + } + ] + } + }], + "tracking_url": "https://tools.usps.com/go/TrackConfirmAction?tLabels=%s", + "test_numbers": { + "valid": [ + "420 22153 9101026837331000039521", + "7196 9010 7560 0307 7385", + "9505 5110 6960 5048 6006 24", + "9101 1234 5678 9000 0000 13", + "92748931507708513018050063", + "92001903060085300042901077", + "9400 1112 0108 0805 4830 16", + "9361 2898 7870 0317 6337 95", + "9405803699300124287899" + ], + "invalid": [ + "61299998820821171811", + "9200000000000000000000", + "420000000000000000000000000000", + "420000009200000000000000000000" + ] + }, + "additional": [ + { + "name": "Service Type", + "regex_group_name": "ServiceType", + "lookup": [ + { + "matches": "11", + "name": "First Class (R)" + }, + { + "matches": "29", + "name": "Fedex Smart Post" + } + ] + } + ] + } + ] +} diff --git a/custom_components/parcelapp/translations/en.json b/custom_components/parcelapp/translations/en.json index 884bc67..781213c 100644 --- a/custom_components/parcelapp/translations/en.json +++ b/custom_components/parcelapp/translations/en.json @@ -42,7 +42,7 @@ }, "courier": { "name": "Courier", - "description": "The courier service handling the parcel. Refer to the list of supported carrier keys, for example, for ACS Courier the key is 'acs'. The list is available at {supported_carriers_url}." + "description": "The courier service handling the parcel. If omitted, the carrier will be auto-detected from the tracking number. Refer to the list of supported carrier keys at {supported_carriers_url}." } } }, @@ -74,7 +74,7 @@ }, "courier": { "name": "Courier", - "description": "The updated courier service handling the parcel. Refer to the list of supported carrier keys, for example, for ACS Courier the key is 'acs'. The list is available at {supported_carriers_url}." + "description": "The updated courier service handling the parcel. If omitted, the carrier will be auto-detected from the tracking number. Refer to the list of supported carrier keys at {supported_carriers_url}." }, "old_number": { "name": "Old Tracking Number", @@ -85,6 +85,16 @@ "description": "The original type of the parcel (e.g., package)" } } + }, + "detect_carrier": { + "name": "Detect Carrier", + "description": "Detect the carrier from a tracking number", + "fields": { + "tracking_number": { + "name": "Tracking Number", + "description": "The tracking number to identify the carrier for" + } + } } } } \ No newline at end of file diff --git a/tests/test_carrier_detection.py b/tests/test_carrier_detection.py new file mode 100644 index 0000000..0dce0e9 --- /dev/null +++ b/tests/test_carrier_detection.py @@ -0,0 +1,256 @@ +"""Tests for carrier auto-detection module.""" + +import json +from pathlib import Path + +import pytest + +from custom_components.parcelapp.carrier_detection import ( + CarrierDetector, + CarrierMatch, + JKEEN_TO_PARCEL_APP_MAP, + TRACKING_DATA_DIR, + EXCLUDED_COURIERS, + _checksum_mod10, + _checksum_mod7, + _checksum_s10, + _checksum_sum_product, + _checksum_luhn, + _checksum_mod_37_36, + _char_to_digit, +) + + +@pytest.fixture +def detector(): + """Create and load a CarrierDetector.""" + d = CarrierDetector() + d.load() + return d + + +def _load_test_numbers(): + """Load all valid/invalid test numbers from tracking data JSON files.""" + valid_cases = [] + invalid_cases = [] + + for json_file in sorted(TRACKING_DATA_DIR.glob("*.json")): + data = json.loads(json_file.read_text(encoding="utf-8")) + courier_code = data.get("courier_code", "") + if courier_code in EXCLUDED_COURIERS: + continue + + for tn in data.get("tracking_numbers", []): + test_nums = tn.get("test_numbers", {}) + fmt_name = tn.get("name", "unknown") + for num in test_nums.get("valid", []): + valid_cases.append((courier_code, fmt_name, num)) + for num in test_nums.get("invalid", []): + invalid_cases.append((courier_code, fmt_name, num)) + + return valid_cases, invalid_cases + + +VALID_CASES, INVALID_CASES = _load_test_numbers() + + +class TestChecksums: + """Test individual checksum algorithms.""" + + def test_char_to_digit(self): + # (ord - 3) % 10 for letters + assert _char_to_digit("R") == 9 # (82-3)%10 = 9 + assert _char_to_digit("A") == 2 # (65-3)%10 = 2 + assert _char_to_digit("Z") == 7 # (90-3)%10 = 7 + assert _char_to_digit("5") == 5 + + def test_mod10_ups(self): + # 1Z5R89390357567127 -> serial "5R89390357567I2", check digit 7 + # UPS mod10: evens=1, odds=2, no digit splitting + assert _checksum_mod10("5R8939035756712", 7, 1, 2) is True + + def test_mod7_dhl(self): + # 3318810025 -> serial "331881002", check 5 + assert _checksum_mod7("331881002", 5) is True + assert _checksum_mod7("331881002", 4) is False + + def test_s10(self): + # RB123456785GB -> serial 12345678, check 5 + assert _checksum_s10([1, 2, 3, 4, 5, 6, 7, 8], 5) is True + assert _checksum_s10([1, 2, 3, 4, 5, 6, 7, 8], 6) is False + + def test_sum_product_fedex_12(self): + # 986578788855 -> serial 98657878885, check 5 + assert ( + _checksum_sum_product( + [9, 8, 6, 5, 7, 8, 7, 8, 8, 8, 5], + 5, + [3, 1, 7, 3, 1, 7, 3, 1, 7, 3, 1], + 11, + 10, + ) + is True + ) + + def test_luhn_old_dominion(self): + # 07209562763 -> serial "0720956276", check 3 + assert _checksum_luhn("0720956276", 3) is True + assert _checksum_luhn("0720956277", 3) is False + + def test_mod_37_36_dpd(self): + # 008182709980000020033350276C + assert _checksum_mod_37_36("008182709980000020033350276", "C") is True + + +class TestDetectorLoading: + """Test that the detector loads patterns correctly.""" + + def test_loads_patterns(self, detector): + assert len(detector._patterns) > 0 + + def test_excludes_s10(self, detector): + codes = {p.courier_code for p in detector._patterns} + assert "s10" not in codes + + def test_includes_major_carriers(self, detector): + codes = {p.courier_code for p in detector._patterns} + assert "ups" in codes + assert "fedex" in codes + assert "usps" in codes + assert "dhl" in codes + + def test_idempotent_load(self, detector): + count = len(detector._patterns) + detector.load() + assert len(detector._patterns) == count + + +class TestDetection: + """Test carrier detection from tracking numbers.""" + + def test_ups_1z(self, detector): + matches = detector.detect("1Z5R89390357567127") + assert len(matches) >= 1 + assert matches[0].courier_code == "ups" + assert matches[0].checksum_valid is True + assert matches[0].confidence == 1.0 + + def test_ups_waybill(self, detector): + matches = detector.detect("K2479825491") + ups_matches = [m for m in matches if m.courier_code == "ups"] + assert len(ups_matches) >= 1 + assert ups_matches[0].checksum_valid is True + + def test_fedex_12(self, detector): + matches = detector.detect("986578788855") + fedex_matches = [m for m in matches if m.courier_code == "fedex"] + assert len(fedex_matches) >= 1 + assert any(m.checksum_valid for m in fedex_matches) + + def test_usps_22(self, detector): + matches = detector.detect("9400111206206406260787") + usps_matches = [m for m in matches if m.courier_code == "usps"] + assert len(usps_matches) >= 1 + + def test_dhl_express(self, detector): + matches = detector.detect("3318810025") + dhl_matches = [m for m in matches if m.courier_code == "dhl"] + assert len(dhl_matches) >= 1 + assert any(m.checksum_valid for m in dhl_matches) + + def test_amazon_tba(self, detector): + matches = detector.detect("TBA000000000000") + amazon_matches = [m for m in matches if m.courier_code == "amazon"] + assert len(amazon_matches) >= 1 + + def test_ontrac_c(self, detector): + matches = detector.detect("C11031500001879") + ontrac_matches = [m for m in matches if m.courier_code == "ontrac"] + assert len(ontrac_matches) >= 1 + assert any(m.checksum_valid for m in ontrac_matches) + + def test_no_match_garbage(self, detector): + matches = detector.detect("XXXXXXXXX") + assert len(matches) == 0 + + def test_no_match_empty(self, detector): + matches = detector.detect("") + assert len(matches) == 0 + + def test_parcel_app_code_mapping(self, detector): + matches = detector.detect("1Z5R89390357567127") + assert matches[0].parcel_app_code == "ups" + + def test_results_sorted_by_confidence(self, detector): + # Use a number that might match multiple patterns + matches = detector.detect("986578788855") + if len(matches) > 1: + for i in range(len(matches) - 1): + assert matches[i].confidence >= matches[i + 1].confidence + + def test_strips_whitespace_in_tracking_number(self, detector): + # UPS with spaces should still match + matches = detector.detect(" 1 Z 8 V 9 2 A 7 0 3 6 7 2 0 3 0 2 4 ") + ups_matches = [m for m in matches if m.courier_code == "ups"] + assert len(ups_matches) >= 1 + + +class TestValidNumbers: + """Parametrized tests using valid test numbers from jkeen data.""" + + @pytest.mark.parametrize( + "courier_code,format_name,tracking_number", + VALID_CASES, + ids=[f"{c[0]}/{c[1]}/{c[2][:20]}" for c in VALID_CASES], + ) + def test_valid_number_detected(self, detector, courier_code, format_name, tracking_number): + """Each valid test number should be detected as its courier with valid checksum.""" + matches = detector.detect(tracking_number) + courier_matches = [m for m in matches if m.courier_code == courier_code] + assert len(courier_matches) >= 1, ( + f"Expected {courier_code}/{format_name} to match {tracking_number!r}, " + f"but got: {[m.courier_code for m in matches]}" + ) + # If the format has a checksum, it should be valid + best = courier_matches[0] + if best.checksum_valid is not None: + assert best.checksum_valid is True, ( + f"Checksum failed for {courier_code}/{format_name}: {tracking_number!r}" + ) + + +class TestInvalidNumbers: + """Parametrized tests using invalid test numbers from jkeen data.""" + + @pytest.mark.parametrize( + "courier_code,format_name,tracking_number", + INVALID_CASES, + ids=[f"{c[0]}/{c[1]}/{c[2][:20]}" for c in INVALID_CASES], + ) + def test_invalid_number_not_validated(self, detector, courier_code, format_name, tracking_number): + """Invalid test numbers should either not match or fail checksum.""" + matches = detector.detect(tracking_number) + courier_matches = [ + m for m in matches if m.courier_code == courier_code and m.checksum_valid is True + ] + assert len(courier_matches) == 0, ( + f"Expected {courier_code}/{format_name} to NOT validate {tracking_number!r}, " + f"but it passed checksum" + ) + + +class TestCarrierCodeMapping: + """Test the carrier code mapping is reasonable.""" + + def test_all_loaded_couriers_have_mapping(self, detector): + """Every non-excluded courier in tracking data should have a Parcel app mapping.""" + loaded_codes = {p.courier_code for p in detector._patterns} + for code in loaded_codes: + assert code in JKEEN_TO_PARCEL_APP_MAP, ( + f"Courier {code!r} loaded but has no Parcel app mapping" + ) + + def test_mapping_values_are_strings(self): + for key, val in JKEEN_TO_PARCEL_APP_MAP.items(): + assert isinstance(val, str) + assert len(val) > 0 From f85dd828bb68ddaaf85642a15ee4100c6fcf79e5 Mon Sep 17 00:00:00 2001 From: Julian De Vita Date: Sat, 4 Apr 2026 19:39:15 -0400 Subject: [PATCH 07/10] fix: deduplicate carrier matches by parcel_app_code and add HA <2025.12 compatibility for description_placeholders --- custom_components/parcelapp/services.py | 39 ++++++++++++++++++------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/custom_components/parcelapp/services.py b/custom_components/parcelapp/services.py index b9182c4..baa809c 100644 --- a/custom_components/parcelapp/services.py +++ b/custom_components/parcelapp/services.py @@ -1,5 +1,6 @@ """The ParcelApp Services.""" +import inspect import json import logging @@ -198,12 +199,21 @@ def _resolve_courier( high_confidence = [m for m in mapped if m.confidence >= 0.8] - if len(high_confidence) == 1: + # Deduplicate by parcel_app_code — multiple formats from the same + # carrier (e.g. USPS 22 and USPS 91) should not trigger disambiguation. + unique_codes = {m.parcel_app_code for m in high_confidence} + + if len(unique_codes) <= 1 and high_confidence: best = high_confidence[0] - elif len(high_confidence) > 1: - options = ", ".join( - f"{m.parcel_app_code} ({m.carrier_name})" for m in high_confidence - ) + elif len(unique_codes) > 1: + # Show only one entry per distinct carrier + seen = set() + options_parts = [] + for m in high_confidence: + if m.parcel_app_code not in seen: + seen.add(m.parcel_app_code) + options_parts.append(f"{m.parcel_app_code} ({m.carrier_name})") + options = ", ".join(options_parts) raise HomeAssistantError( f"Multiple carriers match tracking number '{tracking_number}': {options}. " f"Please provide the 'courier' field to disambiguate." @@ -585,9 +595,16 @@ async def async_detect_carrier(call: ServiceCall): "best_match": matches[0].parcel_app_code if matches else None, } - description_placeholders = { - "supported_carriers_url": CARRIER_CODE_ENDPOINT, - } + # description_placeholders was added in HA 2025.12 + _supports_placeholders = "description_placeholders" in inspect.signature( + hass.services.async_register + ).parameters + + placeholders_kwargs: dict = {} + if _supports_placeholders: + placeholders_kwargs["description_placeholders"] = { + "supported_carriers_url": CARRIER_CODE_ENDPOINT, + } hass.services.async_register( DOMAIN, @@ -595,7 +612,7 @@ async def async_detect_carrier(call: ServiceCall): async_add_parcel, schema=ADD_PARCEL_SCHEMA, supports_response=SupportsResponse.OPTIONAL, - description_placeholders=description_placeholders, + **placeholders_kwargs, ) hass.services.async_register( @@ -604,7 +621,7 @@ async def async_detect_carrier(call: ServiceCall): async_delete_parcel, schema=DELETE_PARCEL_SCHEMA, supports_response=SupportsResponse.OPTIONAL, - description_placeholders=description_placeholders, + **placeholders_kwargs, ) hass.services.async_register( @@ -613,7 +630,7 @@ async def async_detect_carrier(call: ServiceCall): async_edit_parcel, schema=EDIT_PARCEL_SCHEMA, supports_response=SupportsResponse.OPTIONAL, - description_placeholders=description_placeholders, + **placeholders_kwargs, ) hass.services.async_register( From beeaeaf9f8e4eb9e3ebd4c8f76cd960fe8abd4a4 Mon Sep 17 00:00:00 2001 From: Julian De Vita Date: Sun, 19 Apr 2026 17:17:41 -0400 Subject: [PATCH 08/10] chore: bump version to 1.8.0-beta.1 --- custom_components/parcelapp/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/parcelapp/manifest.json b/custom_components/parcelapp/manifest.json index e053946..9b8a413 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.3" + "version": "1.8.0-beta.1" } From a500aefcb0268697b4595a617d12ed7ec6c2409a Mon Sep 17 00:00:00 2001 From: Julian De Vita Date: Sun, 19 Apr 2026 17:20:56 -0400 Subject: [PATCH 09/10] fix: resolve merge conflict in test_sensors.py for delivery window attributes --- tests/test_sensors.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_sensors.py b/tests/test_sensors.py index 64d6a90..e82d5a4 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -2,6 +2,7 @@ import json from datetime import datetime, timedelta, date from unittest.mock import AsyncMock, Mock +from dateutil.parser import parse as dateparse from pathlib import Path from custom_components.parcelapp.sensor import RecentShipment, ActiveShipment, CollectionShipment from custom_components.parcelapp.coordinator import ParcelUpdateCoordinator @@ -113,9 +114,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, + 'date_expected_end': dateparse(recent_data["deliveries"][0]["date_expected_end"]).date(), + 'timestamp_expected': datetime.fromtimestamp(recent_data["deliveries"][0]["timestamp_expected"]), + 'timestamp_expected_end': datetime.fromtimestamp(recent_data["deliveries"][0]["timestamp_expected_end"]), 'extra_information': 'FedEx SmartPost', 'days_until_next_delivery': 1, 'event': 'Departure Scan', From 61aa1e68576a0dd25b7882574e355359c29df551 Mon Sep 17 00:00:00 2001 From: Julian De Vita Date: Mon, 20 Apr 2026 22:04:54 -0400 Subject: [PATCH 10/10] chore: bump version to 1.8.0 --- custom_components/parcelapp/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/parcelapp/manifest.json b/custom_components/parcelapp/manifest.json index 9b8a413..54ecce7 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.8.0-beta.1" + "version": "1.8.0" }