Updated apps

This commit is contained in:
2026-07-09 13:30:12 -04:00
parent 0f7585754d
commit 6cc7212cef
262 changed files with 16124 additions and 107 deletions
@@ -0,0 +1,93 @@
"""Amazon Order Status integration."""
import logging
from datetime import timedelta
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN, SERVICE_PURGE_ORDER, ATTR_ORDER_ID
from .coordinator import AmazonOrdersCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = ["sensor"]
DEFAULT_UPDATE_INTERVAL = 5 # minutes
PURGE_ORDER_SCHEMA = vol.Schema(
{vol.Optional(ATTR_ORDER_ID, default=""): cv.string}
)
async def _handle_purge_order(hass: HomeAssistant, call: ServiceCall) -> None:
"""Remove a specific order from tracking."""
order_id = (call.data.get(ATTR_ORDER_ID) or "").strip()
if not order_id:
_LOGGER.warning(
"purge_order called with empty order_id. "
"If using a dashboard button, call script.purge_amazon_order instead so the order ID is read when you tap."
)
return
domain_data = hass.data.get(DOMAIN) or {}
removed = False
for key, value in domain_data.items():
if isinstance(value, AmazonOrdersCoordinator):
if await value.async_purge_order(order_id):
removed = True
if not removed:
_LOGGER.warning("Order %s not found or already purged", order_id)
def _make_purge_order_handler(hass: HomeAssistant):
"""Return an async service handler that closes over hass."""
async def handler(call: ServiceCall) -> None:
await _handle_purge_order(hass, call)
return handler
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Amazon Order Status from a config entry."""
# Create the coordinator
coordinator = AmazonOrdersCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
# Ensure DOMAIN dict exists
hass.data.setdefault(DOMAIN, {})
# Store coordinator under a fixed key for options_flow
hass.data[DOMAIN]["coordinator"] = coordinator
# Also store by entry_id for platform setup
hass.data[DOMAIN][entry.entry_id] = coordinator
# Register purge_order service (idempotent if multiple entries)
if not hass.services.has_service(DOMAIN, SERVICE_PURGE_ORDER):
hass.services.async_register(
DOMAIN,
SERVICE_PURGE_ORDER,
_make_purge_order_handler(hass),
schema=PURGE_ORDER_SCHEMA,
)
# Forward entry setups (sensors)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
# Unload platforms
await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
# Remove coordinator references
if DOMAIN in hass.data:
if "coordinator" in hass.data[DOMAIN]:
hass.data[DOMAIN].pop("coordinator")
if entry.entry_id in hass.data[DOMAIN]:
hass.data[DOMAIN].pop(entry.entry_id)
return True
@@ -0,0 +1,101 @@
"""Config flow for Amazon Order Status integration."""
from homeassistant import config_entries
import voluptuous as vol
import imaplib
import socket
from .const import DOMAIN, CONF_IMAP_FOLDER
from .options_flow import AmazonOrderStatusOptionsFlow
def _select_folder_quoted(imap, folder: str) -> None:
"""Select IMAP mailbox using quoted name (IMAP4rev2-compatible)."""
quoted = '"' + folder.replace("\\", "\\\\").replace('"', '\\"') + '"'
imap._simple_command("SELECT", quoted.encode("utf-8"))
async def validate_imap_config(hass, host, port, username, password, folder=None):
"""Test connection to IMAP server."""
def _validate():
try:
imap = imaplib.IMAP4_SSL(host, port)
imap.login(username, password)
folder_to_test = folder.strip() if folder and folder.strip() else "INBOX"
_select_folder_quoted(imap, folder_to_test)
imap.logout()
return None
except imaplib.IMAP4.error:
return "invalid_auth"
except (OSError, socket.gaierror):
return "cannot_connect"
return await hass.async_add_executor_job(_validate)
class AmazonOrdersConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Amazon Orders."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
async def async_step_user(self, user_input=None):
"""Handle the initial step of the config flow."""
errors = {}
if user_input is not None:
# Validate IMAP connection
error = await validate_imap_config(
self.hass,
user_input["imap_server"],
user_input.get("imap_port", 993),
user_input["username"],
user_input["password"],
user_input.get(CONF_IMAP_FOLDER),
)
if error is None:
# Create config entry with initial options including mark_as_read
return self.async_create_entry(
title="Amazon Orders",
data={
"email": user_input["email"],
"imap_server": user_input["imap_server"],
"username": user_input["username"],
"password": user_input["password"],
"imap_port": user_input.get("imap_port", 993),
},
options={
"update_interval": user_input.get("poll_interval", 5),
"delivered_retention_days": 30,
"mark_as_read": user_input.get("mark_as_read", True),
CONF_IMAP_FOLDER: user_input.get(CONF_IMAP_FOLDER, ""),
},
)
errors["base"] = error
# Show the form with mark_as_read option
schema = vol.Schema(
{
vol.Required("email"): str,
vol.Required("imap_server"): str,
vol.Required("username"): str,
vol.Required("password"): str,
vol.Optional("imap_port", default=993): int,
vol.Optional("poll_interval", default=5): int,
vol.Optional("mark_as_read", default=True): bool,
vol.Optional(CONF_IMAP_FOLDER, default=""): str,
}
)
return self.async_show_form(
step_id="user",
data_schema=schema,
errors=errors,
)
@staticmethod
def async_get_options_flow(config_entry):
"""Return the options flow handler for this config entry."""
return AmazonOrderStatusOptionsFlow(config_entry)
@@ -0,0 +1,18 @@
DOMAIN = "amazon_order_status"
CONF_EMAIL = "email"
CONF_IMAP_SERVER = "imap_server"
CONF_IMAP_PORT = "imap_port"
CONF_USERNAME = "username"
CONF_PASSWORD = "password"
CONF_POLL_INTERVAL = "poll_interval"
CONF_MARK_AS_READ = "mark_as_read"
CONF_DELIVERED_RETENTION_DAYS = "delivered_retention_days"
CONF_IMAP_FOLDER = "imap_folder"
ERROR_CANNOT_CONNECT = "cannot_connect"
ERROR_INVALID_AUTH = "invalid_auth"
SERVICE_PURGE_ORDER = "purge_order"
ATTR_ORDER_ID = "order_id"
DEFAULT_POLL_INTERVAL = 1800 # 30 minutes
@@ -0,0 +1,488 @@
"""Amazon Orders Data Coordinator."""
from __future__ import annotations
import imaplib
import logging
import re
from datetime import datetime, timedelta, timezone
import email.utils
from email import message_from_bytes
from email.header import decode_header
from typing import Dict
from homeassistant.core import HomeAssistant, callback
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.helpers.storage import Store
import html
from bs4 import BeautifulSoup
from .const import CONF_MARK_AS_READ, CONF_IMAP_FOLDER
_LOGGER = logging.getLogger(__name__)
def _parse_internaldate(internaldate_str: str) -> datetime | None:
"""Parse IMAP INTERNALDATE string to timezone-aware UTC datetime."""
try:
# Format: "08-Feb-2025 18:30:00 +0000" or "08-Feb-2025 10:30:00 -0800"
internaldate_str = internaldate_str.strip()
if len(internaldate_str) < 26:
return None
dt = datetime.strptime(internaldate_str[:20], "%d-%b-%Y %H:%M:%S")
tz_str = internaldate_str[20:].strip()
if not tz_str:
return dt.replace(tzinfo=timezone.utc)
sign = -1 if tz_str[0] == "-" else 1
hours = int(tz_str[1:3])
mins = int(tz_str[3:5]) if len(tz_str) >= 5 else 0
tz = timezone(timedelta(minutes=sign * (hours * 60 + mins)))
return dt.replace(tzinfo=tz).astimezone(timezone.utc)
except (ValueError, IndexError):
return None
def _to_utc(dt: datetime) -> datetime:
"""Normalize a datetime to UTC for comparison."""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
LAST_CHECK_KEY = "last_check"
STORAGE_VERSION = 1
STORAGE_KEY = "amazon_order_status"
ORDERS_KEY = "orders"
ORDER_REGEX = re.compile(
r"(?:Order|Purchase)\s*(?:#|number|ID|No\.?)?\s*[:#]?\s*"
r"([0-9]{3}-[0-9]{7}-[0-9]{7}|[0-9\-]{10,})",
re.IGNORECASE,
)
# IMAP INTERNALDATE format: "08-Feb-2025 18:30:00 +0000"
INTERNALDATE_RE = re.compile(r'INTERNALDATE\s+"([^"]+)"', re.IGNORECASE)
# English month abbreviations for IMAP date (RFC 3501); avoid locale-dependent strftime
_IMAP_MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
def _imap_date_str(dt: datetime) -> str:
"""Return date in IMAP format dd-Mon-yyyy (English month) for SEARCH SINCE."""
return f"{dt.day:02d}-{_IMAP_MONTHS[dt.month - 1]}-{dt.year}"
STATUS_MAP = {
"successfully placed your order": "Ordered",
"we've received your order": "Ordered",
"preparing your automatic refill order": "Ordered",
"automatic refill order": "Ordered",
"ordered": "Ordered",
"shipped": "Shipped",
"out for delivery": "Out for delivery",
"delivered": "Delivered",
}
def _select_folder(mail: imaplib.IMAP4, folder: str) -> None:
"""Select an IMAP mailbox. Use standard select() when safe; otherwise send SELECT line ourselves.
For names with space or parentheses, imaplib sends SELECT Test folder (unquoted), which
FastMail rejects as 'Invalid modifier list'. Sending bytes via _command can be treated
as a literal. So we build and send the exact line: TAG SELECT "folder"\r\n via mail.send(),
then wait for the response and set state.
"""
if any(c in folder for c in " ()"):
mail.untagged_responses = {}
quoted = '"' + folder.replace("\\", "\\\\").replace('"', '\\"') + '"'
tag = mail._new_tag()
tag_str = tag.decode("ascii") if isinstance(tag, bytes) else tag
line = tag_str + " SELECT " + quoted + "\r\n"
mail.send(line.encode("utf-8"))
typ, data = mail._command_complete("SELECT", tag)
if typ != "OK":
msg = data[-1].decode("utf-8", "replace") if data and isinstance(data[-1], bytes) else str(data)
raise mail.error(
"Mailbox %r not found: %s. Check the folder name in integration options "
"(case-sensitive). Use INBOX for the main inbox, or try a path like INBOX.Test folder."
% (folder, msg)
)
mail.state = "SELECTED"
else:
mail.select(folder)
class AmazonOrdersCoordinator(DataUpdateCoordinator):
"""Coordinator to fetch and track Amazon orders via email."""
def __init__(self, hass: HomeAssistant, entry: ConfigEntry):
self.hass = hass
self.entry = entry
self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
self._orders: Dict[str, dict] = {}
self.delivered_retention_days = entry.options.get("delivered_retention_days", 7)
self._mark_as_read = entry.options.get(CONF_MARK_AS_READ, False)
# Get IMAP folder from options, default to "INBOX" if empty or not set
folder = entry.options.get(CONF_IMAP_FOLDER, "")
self._imap_folder = folder.strip() if folder and folder.strip() else "INBOX"
self.last_check: datetime | None = None
# Determine update interval from options or config entry, default 5 min
interval_minutes = entry.options.get(
"update_interval", entry.data.get("update_interval", 5)
)
super().__init__(
hass,
_LOGGER,
name="Amazon Order Status",
update_interval=timedelta(minutes=interval_minutes),
)
""" Timestamp storage/retrieval functions to reduce email check time """
async def async_load_last_check(self) -> datetime | None:
stored = await self._store.async_load()
if stored and LAST_CHECK_KEY in stored:
return datetime.fromisoformat(stored[LAST_CHECK_KEY])
return None
async def async_load_stored_orders(self) -> None:
"""Load persisted orders from storage."""
stored = await self._store.async_load()
if stored:
self._orders = stored.get(ORDERS_KEY, {})
_LOGGER.debug("Loaded %d stored Amazon orders", len(self._orders))
else:
self._orders = {}
_LOGGER.debug("No stored Amazon orders found")
async def async_save_state(self, last_check: datetime) -> None:
await self._store.async_save(
{
LAST_CHECK_KEY: last_check.isoformat(),
ORDERS_KEY: self._orders,
}
)
async def _async_update_data(self):
_LOGGER.debug("Coordinator update triggered at %s", datetime.now(timezone.utc))
if not self._orders:
await self.async_load_stored_orders()
last_check = await self.async_load_last_check()
now = datetime.now(timezone.utc)
self.last_check = now
await self.hass.async_add_executor_job(
self._fetch_and_parse_emails,
last_check,
now,
)
# Purge old delivered orders
self._purge_old_delivered_orders(now)
await self.async_save_state(now)
# Include order_id in each item so sensors and services can use it
return [{**v, "order_id": k} for k, v in self._orders.items()]
@callback
def async_update_interval(self, minutes: int):
"""Dynamically update the coordinator's refresh interval."""
self.update_interval = timedelta(minutes=minutes)
_LOGGER.debug("Coordinator update interval set to %d minutes", minutes)
self.hass.async_create_task(self.async_refresh())
@callback
def async_set_retention_days(self, days: int):
"""Update delivered retention days and immediately purge old delivered orders."""
self.delivered_retention_days = days
_LOGGER.debug("Delivered retention days updated to %d", days)
self._purge_old_delivered_orders(datetime.now(timezone.utc))
@callback
def async_set_mark_as_read(self, mark_as_read: bool):
"""Enable or disable marking emails as read."""
self._mark_as_read = mark_as_read
_LOGGER.debug("Mark as read option updated: %s", mark_as_read)
# Update the config entry safely
new_options = dict(self.entry.options)
new_options[CONF_MARK_AS_READ] = mark_as_read
self.hass.config_entries.async_update_entry(self.entry, options=new_options)
@callback
def async_set_imap_folder(self, folder: str):
"""Update the IMAP folder to search."""
folder_clean = folder.strip() if folder and folder.strip() else "INBOX"
self._imap_folder = folder_clean
_LOGGER.debug("IMAP folder updated to: %s", self._imap_folder)
# Update the config entry safely
new_options = dict(self.entry.options)
new_options[CONF_IMAP_FOLDER] = folder
self.hass.config_entries.async_update_entry(self.entry, options=new_options)
@callback
def _purge_old_delivered_orders(self, now: datetime):
"""Remove delivered orders older than retention period."""
if not self._orders:
return
retention_cutoff = now - timedelta(days=self.delivered_retention_days)
to_remove = [
order_id
for order_id, order in self._orders.items()
if order.get("status") == "Delivered"
and datetime.fromisoformat(order.get("updated")) < retention_cutoff
]
for order_id in to_remove:
_LOGGER.debug(
"Purging delivered order %s (older than %d days)",
order_id,
self.delivered_retention_days,
)
self._orders.pop(order_id, None)
async def async_purge_order(self, order_id: str) -> bool:
"""Remove a specific order from tracking and persist state. Returns True if removed."""
if order_id not in self._orders:
return False
self._orders.pop(order_id, None)
_LOGGER.debug("Purged order %s by user request", order_id)
now = self.last_check or datetime.now(timezone.utc)
await self.async_save_state(now)
self.async_set_updated_data(
[{**v, "order_id": k} for k, v in self._orders.items()]
)
return True
def _fetch_and_parse_emails(self, last_check: datetime | None, now: datetime):
"""Connect to IMAP and parse Amazon emails."""
email_addr = self.entry.data["email"]
password = self.entry.data["password"]
imap_server = self.entry.data["imap_server"]
mark_as_read = self._mark_as_read
_LOGGER.debug("Connecting to IMAP server %s as %s", imap_server, email_addr)
mail = imaplib.IMAP4_SSL(imap_server)
mail.login(email_addr, password)
# Send SELECT with mailbox as a quoted string so IMAP4rev2 servers (e.g. FastMail)
# do not misparse the command as having an invalid modifier list (RFC 9051).
_select_folder(mail, self._imap_folder)
_LOGGER.debug("Selected IMAP folder: %s", self._imap_folder)
if last_check:
since = last_check
_LOGGER.debug("Checking emails since last run: %s", since)
else:
since = now - timedelta(days=14)
_LOGGER.debug("First run: checking last 14 days")
since_utc = _to_utc(since)
# IMAP SINCE is interpreted in the server's timezone (e.g. Gmail uses PST), so
# using the UTC date can ask for "future" emails and return 0. Use one day
# earlier so we always fetch recent emails; we filter by since_utc in Python.
since_date_imap = _imap_date_str(since_utc - timedelta(days=1))
# No charset for date-only criterion; some servers reject CHARSET with SINCE
typ, data = mail.search(None, f'(SINCE "{since_date_imap}")')
if typ != "OK":
_LOGGER.error("IMAP search failed")
mail.logout()
return
email_nums = data[0].split()
_LOGGER.debug(
"Found %d emails since %s (processing all; applying updates when email is newer than stored)",
len(email_nums),
since_date_imap,
)
for num in email_nums:
typ, msg_data = mail.fetch(num, "(BODY.PEEK[] INTERNALDATE)")
if typ != "OK" or not msg_data or not msg_data[0]:
_LOGGER.warning("Failed to fetch email %s", num)
continue
# Prefer INTERNALDATE (when mailbox received the message) over Date header
raw_response = msg_data[0][0]
if isinstance(raw_response, bytes):
raw_response = raw_response.decode(errors="ignore")
internaldate_utc = None
internaldate_match = INTERNALDATE_RE.search(raw_response)
if internaldate_match:
internaldate_utc = _parse_internaldate(internaldate_match.group(1))
msg = message_from_bytes(msg_data[0][1])
msg_date = msg.get("Date")
msg_datetime = None
if msg_date:
msg_datetime = email.utils.parsedate_to_datetime(msg_date)
if msg_datetime.tzinfo is None:
msg_datetime = msg_datetime.replace(tzinfo=timezone.utc)
else:
msg_datetime = msg_datetime.astimezone(timezone.utc)
received_utc = internaldate_utc if internaldate_utc is not None else msg_datetime
if received_utc is None:
_LOGGER.debug("Email %s: no INTERNALDATE or Date header, skipping", num)
continue
# Ignore emails that arrived before our last check (avoids re-adding purged orders)
if last_check is not None and received_utc < since_utc:
_LOGGER.debug(
"Email %s: received %s before last check %s, skipping",
num,
received_utc,
since_utc,
)
continue
subject = self._decode_header(msg.get("Subject", ""))
subject_lower = subject.lower()
status = self._status_from_subject(subject_lower)
if not status:
_LOGGER.debug(
"Email %s: subject not recognized as order status, skipping: %s",
num,
subject[:80],
)
continue
body_text = self._extract_text(msg)
html_body = self._extract_html(msg)
# Collect order IDs from both plain text and HTML; many "shipped" emails
# put the order number only in the HTML part, so we must check both.
order_ids = list(
dict.fromkeys(
ORDER_REGEX.findall(body_text)
+ (ORDER_REGEX.findall(html_body) if html_body else [])
)
)
if not order_ids:
_LOGGER.debug(
"No order numbers found in email (subject: %s). "
"Checked plain text (%d chars) and HTML (%d chars).",
subject,
len(body_text),
len(html_body) if html_body else 0,
)
continue
tracking_url = self._extract_tracking_url(html_body) if html_body else None
updated_ts = (msg_datetime if msg_datetime is not None else received_utc).isoformat()
did_update = False
for order_id in order_ids:
# Only overwrite if we don't have this order or this email is newer than stored
existing = self._orders.get(order_id)
if existing:
try:
existing_updated = _to_utc(
datetime.fromisoformat(existing["updated"])
)
if received_utc < existing_updated:
_LOGGER.debug(
"Order %s: skipping (email %s older than stored)",
order_id,
received_utc,
)
continue
except (ValueError, TypeError):
pass
self._orders[order_id] = {
"status": status,
"subject": subject,
"updated": updated_ts,
"tracking_url": tracking_url,
}
did_update = True
_LOGGER.debug(
"Order %s%s (%s) [tracking: %s]",
order_id,
status,
subject,
tracking_url,
)
if mark_as_read:
_LOGGER.debug("Marking email %s as read (order email processed)", num)
mail.store(num, "+FLAGS", "\\Seen")
mail.logout()
def _status_from_subject(self, subject: str) -> str | None:
"""Determine order status from email subject."""
for key, value in STATUS_MAP.items():
if key in subject:
return value
return None
def _decode_header(self, value: str) -> str:
"""Decode email headers safely."""
parts = decode_header(value)
decoded = ""
for text, encoding in parts:
if isinstance(text, bytes):
decoded += text.decode(encoding or "utf-8", errors="ignore")
else:
decoded += text
return decoded
def _extract_text(self, msg) -> str:
"""Extract flattened text from email."""
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() in ("text/plain", "text/html"):
payload = part.get_payload(decode=True)
if payload:
return payload.decode(errors="ignore")
else:
payload = msg.get_payload(decode=True)
if payload:
return payload.decode(errors="ignore")
return ""
def _extract_html(self, msg) -> str:
"""Extract HTML body from email."""
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/html":
payload = part.get_payload(decode=True)
if payload:
return payload.decode(errors="ignore")
else:
if msg.get_content_type() == "text/html":
payload = msg.get_payload(decode=True)
if payload:
return payload.decode(errors="ignore")
return ""
def _extract_tracking_url(self, html_body: str) -> str | None:
"""Extract Amazon tracking URL or order link from email HTML."""
soup = BeautifulSoup(html_body, "html.parser")
for link in soup.find_all("a", href=True):
text = link.get_text(" ", strip=True).lower()
href = html.unescape(link["href"])
# Case 1: Tracking links
if "track package" in text or "progress-tracker" in href:
match = re.search(
r"https://www\.amazon\.com/progress-tracker/[^&\"]+",
href,
)
if match:
return match.group(0)
return href
# Case 2: Order management links
if "your-orders" in href and ("view" in text or "edit order" in text):
return href
return None
@@ -0,0 +1,12 @@
{
"domain": "amazon_order_status",
"name": "Amazon Order Status",
"codeowners": ["@koconnorgit"],
"config_flow": true,
"documentation": "https://github.com/koconnorgit/ha-amazon-order-status",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/koconnorgit/ha-amazon-order-status/issues",
"requirements": ["beautifulsoup4"],
"version": "1.4.5"
}
@@ -0,0 +1,78 @@
"""Options flow for Amazon Order Status integration."""
from homeassistant import config_entries
import voluptuous as vol
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN, CONF_IMAP_FOLDER
class AmazonOrderStatusOptionsFlow(config_entries.OptionsFlow):
"""Handle an options flow for Amazon Order Status."""
def __init__(self, config_entry: config_entries.ConfigEntry):
self._config_entry = config_entry
async def async_step_init(self, user_input=None):
"""Manage the initial step of the options flow."""
if user_input is not None:
# Merge new options with existing ones
new_options = dict(self._config_entry.options)
new_options.update(user_input)
# Update the config entry
self.hass.config_entries.async_update_entry(
self._config_entry, options=new_options
)
# Apply updates to the coordinator if it exists
coordinator = self.hass.data.get(DOMAIN, {}).get("coordinator")
if coordinator:
if "delivered_retention_days" in user_input:
coordinator.async_set_retention_days(
user_input["delivered_retention_days"]
)
if "update_interval" in user_input:
coordinator.async_update_interval(
user_input["update_interval"]
)
if "mark_as_read" in user_input:
coordinator.async_set_mark_as_read(
user_input["mark_as_read"]
)
if CONF_IMAP_FOLDER in user_input:
coordinator.async_set_imap_folder(
user_input[CONF_IMAP_FOLDER]
)
return self.async_create_entry(title="", data=user_input)
# Current options to use as defaults
options = self._config_entry.options
# Standard vol.Schema
schema = vol.Schema(
{
vol.Required(
"delivered_retention_days",
default=options.get("delivered_retention_days", 30),
): cv.positive_int,
vol.Required(
"update_interval",
default=options.get("update_interval", 5),
): cv.positive_int,
vol.Required(
"mark_as_read",
default=options.get("mark_as_read", True),
): cv.boolean,
vol.Optional(
CONF_IMAP_FOLDER,
default=options.get(CONF_IMAP_FOLDER, ""),
): str,
}
)
return self.async_show_form(
step_id="init",
data_schema=schema,
)
@@ -0,0 +1,13 @@
# Copy this script into Home Assistant so the Purge order button works.
#
# Settings → Automations & scenes → Scripts → Add script → (paste in YAML mode)
#
# IMPORTANT: Use "data_template" so the order_id is read when you tap the button.
# Using "data" would pass the literal template string and the order would not be found.
#
purge_amazon_order:
alias: Purge Amazon order
sequence:
- service: amazon_order_status.purge_order
data_template:
order_id: "{{ states('input_text.amazon_order_purge_id') }}"
@@ -0,0 +1,95 @@
"""Amazon Orders sensors."""
import logging
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from datetime import datetime
from homeassistant.components.sensor import SensorEntity
from .coordinator import AmazonOrdersCoordinator
from homeassistant.util.dt import as_local
_LOGGER = logging.getLogger(__name__)
STATUSES = [
"Ordered",
"Shipped",
"Out for delivery",
"Delivered",
]
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities,
):
"""Set up Amazon Order Status sensors."""
coordinator: AmazonOrdersCoordinator = hass.data["amazon_order_status"][entry.entry_id]
sensors = [
AmazonOrderStatusSensor(coordinator, status)
for status in STATUSES
]
sensors.append(AmazonOrdersLastUpdatedSensor(coordinator))
async_add_entities(sensors)
class AmazonOrderStatusSensor(CoordinatorEntity, Entity):
"""Sensor representing Amazon orders in a specific status."""
def __init__(self, coordinator: AmazonOrdersCoordinator, status: str):
super().__init__(coordinator)
self.status = status
self._attr_unique_id = f"amazon_order_status_{status.lower().replace(' ', '_')}"
self._attr_name = f"Amazon Orders {status}"
self._attr_icon = "mdi:package-variant"
@property
def state(self) -> int:
"""Return number of orders in this status."""
return len(self._orders_for_status())
@property
def extra_state_attributes(self):
"""Return order details for this status."""
orders = self._orders_for_status()
return {
"order_count": len(orders),
"orders": orders,
}
def _orders_for_status(self) -> list[dict]:
"""Return orders matching this sensor's status."""
if not self.coordinator.data:
return []
return [
{
"order_id": data.get("order_id"),
"subject": data.get("subject"),
"updated": data.get("updated"),
"tracking_url": data.get("tracking_url"),
}
for data in self.coordinator.data
if data.get("status") == self.status
]
class AmazonOrdersLastUpdatedSensor(CoordinatorEntity, Entity):
"""Sensor showing when Amazon orders were last updated."""
def __init__(self, coordinator: AmazonOrdersCoordinator):
super().__init__(coordinator)
self._attr_unique_id = "amazon_order_status_last_updated"
self._attr_name = "Amazon Orders Last Updated"
self._attr_icon = "mdi:clock-check-outline"
@property
def state(self):
"""Return ISO timestamp of last successful update."""
if not getattr(self.coordinator, "last_check", None):
return None
return self.coordinator.last_check.isoformat()
@@ -0,0 +1,10 @@
# Service definitions for Amazon Order Status
# https://developers.home-assistant.io/docs/dev_101_services
purge_order:
description: Remove a specific order from tracking. The order will no longer appear in the sensors.
fields:
order_id:
description: The Amazon order number to remove (e.g. 123-4567890-1234567). Copy from sensor attributes.
required: true
example: "123-4567890-1234567"
@@ -0,0 +1,34 @@
{
"config": {
"error": {
"cannot_connect": "Cannot connect to IMAP server",
"invalid_auth": "Invalid username or password"
},
"step": {
"user": {
"title": "Amazon Orders",
"description": "Configure IMAP access for Amazon order emails. imap_folder is an optional field - leave blank for the root inbox, or provide a folder name to search a specific folder. Note that some email providers require 'INBOX/Folder name', others will accept simply 'Folder name'"
}
}
},
"options": {
"step": {
"init": {
"title": "Amazon Order Options",
"description": "Configure how Home Assistant tracks and processes Amazon order emails. delivered_retention_days - How many days should HA track delivered orders? update_interval - how often should HA check for delivery updates? imap_folder - Optional email folder to search (leave empty for INBOX)."
}
}
},
"services": {
"purge_order": {
"name": "Purge order",
"description": "Remove a specific order from tracking. The order will no longer appear in the Amazon Order Status sensors.",
"fields": {
"order_id": {
"name": "Order ID",
"description": "The Amazon order number to remove (e.g. 123-4567890-1234567). You can copy this from the sensor's order list."
}
}
}
}
}