From 444766282ce9d490701e55a74ef05d0d2d82208e Mon Sep 17 00:00:00 2001 From: Julian De Vita Date: Sat, 4 Apr 2026 19:03:00 -0400 Subject: [PATCH 1/2] 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 2/2] 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(