diff --git a/README.md b/README.md index 93ee1bc..259f9be 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,8 @@ Feel free to make a pull request with your template under docs/community_templat \ Example Bubble Card +[Push Notification - Parcel Added](/docs/community_templates/push_notification_parcel_added.yaml) by @BenSmartHome + Development ----------- diff --git a/custom_components/parcelapp/manifest.json b/custom_components/parcelapp/manifest.json index 509ebf6..5193292 100644 --- a/custom_components/parcelapp/manifest.json +++ b/custom_components/parcelapp/manifest.json @@ -11,5 +11,5 @@ "requests>=2.32.3", "python-dateutil>=2.9.0" ], - "version": "1.7.1" + "version": "1.7.2" } diff --git a/custom_components/parcelapp/services.py b/custom_components/parcelapp/services.py index 403ab20..8e50bd6 100644 --- a/custom_components/parcelapp/services.py +++ b/custom_components/parcelapp/services.py @@ -1,15 +1,25 @@ """The ParcelApp Services.""" +import json import logging from aiohttp import ClientResponseError import voluptuous as vol -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import COURIER, DOMAIN, PARCEL_NAME, TRACKING_NUMBER, TYPE, OLD_NUMBER, OLD_TYPE +from .const import ( + COURIER, + DOMAIN, + OLD_NUMBER, + OLD_TYPE, + PARCEL_NAME, + TRACKING_NUMBER, + TYPE, +) _LOGGER = logging.getLogger(__name__) @@ -19,6 +29,7 @@ vol.Required(PARCEL_NAME): cv.string, vol.Required(TRACKING_NUMBER): cv.string, vol.Required(COURIER): cv.string, + vol.Required("send_push_confirmation", default=False): cv.boolean, } ) @@ -41,6 +52,7 @@ } ) + async def async_get_config_entry_from_device_id(hass: HomeAssistant, device_id: str): """Get the config entry from a device ID.""" device_registry = dr.async_get(hass) @@ -55,63 +67,237 @@ async def async_get_config_entry_from_device_id(hass: HomeAssistant, device_id: return entry return None + +async def async_create_notification( + hass: HomeAssistant, + title: str, + message: str, + notification_id: str, +) -> None: + """Create a persistent notification in Home Assistant.""" + await hass.services.async_call( + "persistent_notification", + "create", + { + "title": title, + "message": message, + "notification_id": notification_id, + }, + ) + + +def get_http_error_message( + status_code: int, + operation: str, + auth_type: str = "api_key", + parcel_name: str | None = None, + tracking_number: str | None = None, + carrier: str | None = None, +) -> str: + """Generate user-friendly error messages based on HTTP status codes.""" + + auth_credential = "API key" if auth_type == "api_key" else "account token" + + # Build context string for the error message + context = "" + if parcel_name and tracking_number: + context = f"'{parcel_name}' (tracking: {tracking_number})" + elif tracking_number: + context = f"(tracking: {tracking_number})" + + if status_code == 400: + if operation == "add": + return ( + f"Failed to add parcel {context}. " + f"The Parcel App API rejected the request (HTTP 400 - Bad Request).\n" + f"Please verify the tracking number and carrier code are correct." + ) + if operation == "delete": + return ( + f"Failed to delete parcel {context}. " + f"Bad request (HTTP 400). Please verify the tracking number and type are correct." + ) + + return ( + f"Failed to edit parcel {context}. " + f"Bad request (HTTP 400). Please verify the tracking number and carrier code are correct." + ) + + if status_code in {401, 403}: + return ( + f"Authentication failed (HTTP {status_code}). " + f"Your {auth_credential} may be invalid or expired. Please reconfigure the integration." + ) + + if status_code == 404: + if operation == "delete": + return ( + f"Parcel not found (HTTP 404). " + f"The parcel with tracking number '{tracking_number}' may have already been deleted." + ) + if operation == "edit": + return ( + "Parcel not found (HTTP 404). " + "The original parcel may not exist or has already been modified." + ) + return "Resource not found (HTTP 404)." + + if status_code == 429: + return ( + "Rate limit exceeded (HTTP 429). " + "The Parcel App API allows 20 requests per day. Please try again later." + ) + + if status_code >= 500: + return ( + f"Parcel App server error (HTTP {status_code}). " + f"The service may be temporarily unavailable. Please try again later." + ) + + return ( + f"Failed to communicate with Parcel App API (HTTP {status_code}). " + f"Please check your internet connection and try again." + ) + + async def async_register_services(hass: HomeAssistant): """Register ParcelApp services.""" session = async_get_clientsession(hass) async def async_add_parcel(call: ServiceCall): - """Add a parcel to ParcelApp.""" + """Add a parcel to ParcelApp using the official API.""" device_id = call.data["device_id"] config_entry = await async_get_config_entry_from_device_id(hass, device_id) if not config_entry: - return + _LOGGER.error("Config entry not found for device: %s", device_id) + raise HomeAssistantError("Config entry not found for device") + + api_key = config_entry.data.get("api_key", "") + if not api_key: + _LOGGER.error("API key not found for device: %s", device_id) + raise HomeAssistantError( + "API key not configured. Please reconfigure the integration with your API key." + ) - account_token = config_entry.data.get("account_token", "") - if not account_token: - _LOGGER.error("Account token not found for device: %s", device_id) - return - parcel_name = call.data[PARCEL_NAME] - tracking_number = call.data[TRACKING_NUMBER] + tracking_number = str(call.data[TRACKING_NUMBER]) courier = call.data[COURIER] + send_push = call.data.get("send_push_confirmation", False) - # Prepare the payload for the API call + # Prepare the payload for the official API payload = { - "name": parcel_name, - "number": tracking_number, - "courier": courier, + "tracking_number": tracking_number, + "carrier_code": courier, + "description": parcel_name, + "send_push_confirmation": send_push, } headers = { - "User-Agent": "Home Assistant", - "Content-Type": "application/x-www-form-urlencoded", - "Cookie": f"account_token={account_token}", + "api-key": api_key, + "Content-Type": "application/json", } + try: - # API Call for Adding Parcel + # Official API Call for Adding Parcel async with session.post( - "https://web.parcelapp.net/add-ajax.php", headers=headers, data=payload + "https://api.parcel.app/external/add-delivery/", + headers=headers, + json=payload, ) as response: response.raise_for_status() - result = await response.text() - _LOGGER.info("Parcel Add Response: %s", result) + result = await response.json() + + if result.get("success"): + _LOGGER.info( + "Successfully added parcel: %s (tracking: %s, carrier: %s)", + parcel_name, + tracking_number, + courier, + ) + # Create success notification + await async_create_notification( + hass, + title="Parcel Added Successfully", + message=f"Successfully added parcel '{parcel_name}' with tracking number {tracking_number}", + notification_id=f"parcelapp_add_{tracking_number}", + ) + + return { + "success": True, + "parcel_name": parcel_name, + "tracking_number": tracking_number, + "carrier": courier, + } + else: + error_msg = result.get("error_message", "Unknown error") + _LOGGER.error( + "Failed to add parcel: %s (tracking: %s, carrier: %s). Error: %s", + parcel_name, + tracking_number, + courier, + error_msg, + ) + raise HomeAssistantError( + f"Failed to add parcel '{parcel_name}' (tracking: {tracking_number}). " + f"The Parcel App API returned an error: {error_msg}" + ) + + except HomeAssistantError: + raise except ClientResponseError as err: - _LOGGER.error("API call failed with status %s: %s", err.status, err.message) - result = "API Call Failed" + _LOGGER.error( + "API call failed with status %s: %s. Parcel: %s, Tracking: %s, Carrier: %s", + err.status, + err.message, + parcel_name, + tracking_number, + courier, + ) + error_msg = get_http_error_message( + status_code=err.status, + operation="add", + auth_type="api_key", + parcel_name=parcel_name, + tracking_number=tracking_number, + carrier=courier, + ) + raise HomeAssistantError(error_msg) from err + except json.JSONDecodeError as err: + _LOGGER.error("Failed to parse API response: %s", err) + raise HomeAssistantError("Received invalid response from Parcel App API") from err except Exception as err: - _LOGGER.error("Unexpected error during API call: %s", err) - result = "Unexpected Error" + _LOGGER.error( + "Unexpected error during API call: %s. Parcel: %s, Tracking: %s, Carrier: %s", + err, + parcel_name, + tracking_number, + courier, + ) + raise HomeAssistantError(f"Unexpected error: {err}") from err async def async_delete_parcel(call: ServiceCall): - """Delete a parcel from ParcelApp.""" - tracking_number = call.data[TRACKING_NUMBER] + """Delete a parcel from ParcelApp (using workaround API).""" + tracking_number = str(call.data[TRACKING_NUMBER]) parcel_type = call.data[TYPE] # Retrieve the account_token from the config entry - config_entry = hass.config_entries.async_entries(DOMAIN)[0] + config_entries_list = hass.config_entries.async_entries(DOMAIN) + if not config_entries_list: + _LOGGER.error("No config entry found for parcelapp domain") + raise HomeAssistantError("No config entry found") + + config_entry = config_entries_list[0] account_token = config_entry.data.get("account_token", "") + if not account_token: + _LOGGER.error( + "Account token not configured. Delete service requires account_token" + ) + raise HomeAssistantError( + "Account token not configured. Please reconfigure the integration." + ) + # Prepare the payload for the API call payload = { "number": tracking_number, @@ -122,39 +308,105 @@ async def async_delete_parcel(call: ServiceCall): "Content-Type": "application/x-www-form-urlencoded", "Cookie": f"account_token={account_token}", } + try: - # API Call for Deleting Parcel + # API Call for Deleting Parcel (BETA ) async with session.post( - "https://web.parcelapp.net/delete-ajax.php", headers=headers, data=payload + "https://web.parcelapp.net/delete-ajax.php", + headers=headers, + data=payload, ) as response: response.raise_for_status() result = await response.text() _LOGGER.info("Parcel Delete Response: %s", result) + # Check if the response indicates an error + if result.strip().upper() == "ERROR": + _LOGGER.error( + "Failed to delete parcel. Tracking: %s, Type: %s", + tracking_number, + parcel_type, + ) + raise HomeAssistantError( + f"Failed to delete parcel (tracking: {tracking_number})" + ) + + _LOGGER.info( + "Successfully deleted parcel. Tracking: %s, Type: %s", + tracking_number, + parcel_type, + ) + + # Create success notification + await async_create_notification( + hass, + title="Parcel Deleted Successfully", + message=f"Successfully deleted parcel with tracking number {tracking_number}", + notification_id=f"parcelapp_delete_{tracking_number}", + ) + + return { + "success": True, + "tracking_number": tracking_number, + "type": parcel_type, + } + + except HomeAssistantError: + raise except ClientResponseError as err: - _LOGGER.error("API call failed with status %s: %s", err.status, err.message) - result = "API Call Failed" + _LOGGER.error( + "API call failed with status %s: %s. Tracking: %s, Type: %s", + err.status, + err.message, + tracking_number, + parcel_type, + ) + error_msg = get_http_error_message( + status_code=err.status, + operation="delete", + auth_type="account_token", + tracking_number=tracking_number, + ) + raise HomeAssistantError(error_msg) from err except Exception as err: - _LOGGER.error("Unexpected error during API call: %s", err) - result = "Unexpected Error" + _LOGGER.error( + "Unexpected error during API call: %s. Tracking: %s, Type: %s", + err, + tracking_number, + parcel_type, + ) + raise HomeAssistantError(f"Unexpected error: {err}") from err async def async_edit_parcel(call: ServiceCall): - """Edit a parcel in ParcelApp.""" + """Edit a parcel in ParcelApp (BETA).""" parcel_name = call.data[PARCEL_NAME] - tracking_number = call.data[TRACKING_NUMBER] + tracking_number = str(call.data[TRACKING_NUMBER]) courier = call.data[COURIER] - old_number = call.data[OLD_NUMBER] + old_number = str(call.data[OLD_NUMBER]) old_type = call.data[OLD_TYPE] # Retrieve the account_token from the config entry - config_entry = hass.config_entries.async_entries(DOMAIN)[0] + config_entries_list = hass.config_entries.async_entries(DOMAIN) + if not config_entries_list: + _LOGGER.error("No config entry found for parcelapp domain") + raise HomeAssistantError("No config entry found") + + config_entry = config_entries_list[0] account_token = config_entry.data.get("account_token", "") + if not account_token: + _LOGGER.error( + "Account token not configured. Edit service requires account_token" + ) + raise HomeAssistantError( + "Account token not configured. Please reconfigure the integration." + ) + # Prepare the payload for the API call payload = { "name": parcel_name, "number": tracking_number, - "courier": courier, + "carrier": courier, "oldNumber": old_number, "oldType": old_type, } @@ -163,8 +415,9 @@ async def async_edit_parcel(call: ServiceCall): "Content-Type": "application/x-www-form-urlencoded", "Cookie": f"account_token={account_token}", } + try: - # API Call for Editing Parcel + # API Call for Editing Parcel (BETA) async with session.post( "https://web.parcelapp.net/edit-ajax.php", headers=headers, data=payload ) as response: @@ -172,18 +425,76 @@ async def async_edit_parcel(call: ServiceCall): result = await response.text() _LOGGER.info("Parcel Edit Response: %s", result) + # Check if the response indicates an error + if result.strip().upper() == "ERROR": + _LOGGER.error( + "Failed to edit parcel: %s. Tracking: %s, Carrier: %s", + parcel_name, + tracking_number, + courier, + ) + raise HomeAssistantError( + f"Failed to edit parcel '{parcel_name}' (tracking: {tracking_number})" + ) + + _LOGGER.info( + "Successfully edited parcel: %s. Tracking: %s, Carrier: %s", + parcel_name, + tracking_number, + courier, + ) + + # Create success notification + await async_create_notification( + hass, + title="Parcel Edited Successfully", + message=f"Successfully edited parcel '{parcel_name}' with tracking number {tracking_number}", + notification_id=f"parcelapp_edit_{tracking_number}", + ) + + return { + "success": True, + "parcel_name": parcel_name, + "tracking_number": tracking_number, + "carrier": courier, + "old_tracking_number": old_number, + "old_carrier": old_type, + } + + except HomeAssistantError: + raise except ClientResponseError as err: - _LOGGER.error("API call failed with status %s: %s", err.status, err.message) - result = "API Call Failed" + _LOGGER.error( + "API call failed with status %s: %s. Parcel: %s, Tracking: %s", + err.status, + err.message, + parcel_name, + tracking_number, + ) + error_msg = get_http_error_message( + status_code=err.status, + operation="edit", + auth_type="account_token", + parcel_name=parcel_name, + tracking_number=tracking_number, + carrier=courier, + ) + raise HomeAssistantError(error_msg) from err except Exception as err: - _LOGGER.error("Unexpected error during API call: %s", err) - result = "Unexpected Error" + _LOGGER.error( + "Unexpected error during API call: %s. Parcel: %s, Tracking: %s", + err, + parcel_name, + tracking_number, + ) + raise HomeAssistantError(f"Unexpected error: {err}") from err hass.services.async_register( DOMAIN, "add_parcel", async_add_parcel, schema=ADD_PARCEL_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, ) hass.services.async_register( @@ -191,6 +502,7 @@ async def async_edit_parcel(call: ServiceCall): "delete_parcel", async_delete_parcel, schema=DELETE_PARCEL_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, ) hass.services.async_register( @@ -198,4 +510,5 @@ async def async_edit_parcel(call: ServiceCall): "edit_parcel", async_edit_parcel, schema=EDIT_PARCEL_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, ) diff --git a/custom_components/parcelapp/services.yaml b/custom_components/parcelapp/services.yaml index dd89407..f81d253 100644 --- a/custom_components/parcelapp/services.yaml +++ b/custom_components/parcelapp/services.yaml @@ -8,20 +8,33 @@ add_parcel: device: integration: parcelapp parcel_name: + name: "Parcel Name" + description: "Name/description for the parcel" example: "My Parcel" required: true selector: text: tracking_number: + name: "Tracking Number" + description: "Tracking number for the parcel" example: "1234567890" required: true selector: text: courier: + name: "Courier" + description: "Courier/carrier code (e.g., ups, fedex, usps)" example: "ups" required: true selector: text: + send_push_confirmation: + name: "Send Push Notification" + description: "Send a push notification when the parcel is added" + required: false + default: false + selector: + boolean: delete_parcel: fields: @@ -53,26 +66,36 @@ edit_parcel: device: integration: parcelapp parcel_name: + name: "Parcel Name" + description: "New name for the parcel" example: "Updated Parcel Name" required: true selector: text: tracking_number: + name: "Tracking Number" + description: "New tracking number" example: "1234567890" required: true selector: text: courier: + name: "Courier" + description: "New courier/carrier code" example: "fedex" required: true selector: text: - old_number: + oldNumber: + name: "Old Tracking Number" + description: "Current tracking number of the parcel to edit" example: "1234567890" required: true selector: text: - old_type: + oldType: + name: "Old Courier" + description: "Current courier/carrier code of the parcel to edit" example: "fedex" required: true selector: diff --git a/docs/community_templates/push_notification_parcel_added.yaml b/docs/community_templates/push_notification_parcel_added.yaml new file mode 100644 index 0000000..37394f5 --- /dev/null +++ b/docs/community_templates/push_notification_parcel_added.yaml @@ -0,0 +1,24 @@ +# Created by @BenSmartHome +# Note: Push notification when a parcel is added successfully. + +alias: Push bei Paket hinzugefügt +triggers: + - event_type: call_service + event_data: + domain: persistent_notification + service: create + trigger: event +conditions: + - condition: template + value_template: | + {{ trigger.event.data.service_data.title == "Parcel Added Successfully" }} +actions: + - data: + title: Paket erfolgreich hinzugefügt + message: Bitte warte ein paar Minuten, bis es im Dashboard angezeigt wird!. + action: notify.mobile_app_iphone_13_pro + - data: + title: Paket erfolgreich hinzugefügt + message: Bitte warte ein paar Minuten, bis es im Dashboard angezeigt wird!. + action: notify.mobile_app_s21_fe +mode: single \ No newline at end of file