Initial commit.

This commit is contained in:
2025-08-30 16:34:17 -04:00
commit f9e55b8704
2194 changed files with 86781 additions and 0 deletions
@@ -0,0 +1,265 @@
"""Mail and Packages Integration."""
import asyncio
import logging
import os
from datetime import timedelta
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_RESOURCES
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
ATTR_AMAZON_IMAGE,
ATTR_IMAGE_NAME,
ATTR_IMAGE_PATH,
CONF_AMAZON_DAYS,
CONF_AMAZON_DOMAIN,
CONF_AMAZON_FWDS,
CONF_IMAGE_SECURITY,
CONF_IMAP_SECURITY,
CONF_IMAP_TIMEOUT,
CONF_PATH,
CONF_SCAN_INTERVAL,
CONF_STORAGE,
CONF_VERIFY_SSL,
CONFIG_VER,
COORDINATOR,
DEFAULT_AMAZON_DAYS,
DOMAIN,
ISSUE_URL,
PLATFORMS,
VERSION,
)
from .helpers import default_image_path, hash_file, process_emails
_LOGGER = logging.getLogger(__name__)
async def async_setup(
hass: HomeAssistant, config_entry: ConfigEntry
): # pylint: disable=unused-argument
"""Disallow configuration via YAML."""
return True
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Load the saved entities."""
_LOGGER.info(
"Version %s is starting, if you have any issues please report them here: %s",
VERSION,
ISSUE_URL,
)
hass.data.setdefault(DOMAIN, {})
updated_config = config_entry.data.copy()
# Sort the resources
updated_config[CONF_RESOURCES] = sorted(updated_config[CONF_RESOURCES])
if updated_config != config_entry.data:
hass.config_entries.async_update_entry(config_entry, data=updated_config)
# Variables for data coordinator
config = config_entry.data
# Setup the data coordinator
coordinator = MailDataUpdateCoordinator(hass, config)
# Fetch initial data so we have data when entities subscribe
await coordinator.async_refresh()
# Raise ConfEntryNotReady if coordinator didn't update
if not coordinator.last_update_success:
_LOGGER.error("Error updating sensor data: %s", coordinator.last_exception)
raise ConfigEntryNotReady
hass.data[DOMAIN][config_entry.entry_id] = {
COORDINATOR: coordinator,
}
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Handle removal of an entry."""
_LOGGER.debug("Attempting to unload sensors from the %s integration", DOMAIN)
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(config_entry, platform)
for platform in PLATFORMS
]
)
)
if unload_ok:
_LOGGER.debug("Successfully removed sensors from the %s integration", DOMAIN)
hass.data[DOMAIN].pop(config_entry.entry_id)
return unload_ok
async def async_migrate_entry(hass, config_entry):
"""Migrate an old config entry."""
version = config_entry.version
new_version = CONFIG_VER
_LOGGER.debug("Migrating from version %s", version)
updated_config = {**config_entry.data}
# 1 -> 4: Migrate format
if version == 1:
if CONF_AMAZON_FWDS in updated_config.keys():
if not isinstance(updated_config[CONF_AMAZON_FWDS], list):
updated_config[CONF_AMAZON_FWDS] = [
x.strip() for x in updated_config[CONF_AMAZON_FWDS].split(",")
]
else:
updated_config[CONF_AMAZON_FWDS] = []
else:
_LOGGER.warning("Missing configuration data: %s", CONF_AMAZON_FWDS)
# Force path change
updated_config[CONF_PATH] = "custom_components/mail_and_packages/images/"
# Always on image security
if not config_entry.data[CONF_IMAGE_SECURITY]:
updated_config[CONF_IMAGE_SECURITY] = True
# Add default Amazon Days configuration
updated_config[CONF_AMAZON_DAYS] = DEFAULT_AMAZON_DAYS
# 2 -> 4
if version <= 2:
# Force path change
updated_config[CONF_PATH] = "custom_components/mail_and_packages/images/"
# Always on image security
if not config_entry.data[CONF_IMAGE_SECURITY]:
updated_config[CONF_IMAGE_SECURITY] = True
# Add default Amazon Days configuration
updated_config[CONF_AMAZON_DAYS] = DEFAULT_AMAZON_DAYS
if version <= 3:
# Add default Amazon Days configuration
updated_config[CONF_AMAZON_DAYS] = DEFAULT_AMAZON_DAYS
if version <= 4:
if CONF_AMAZON_FWDS in updated_config and updated_config[CONF_AMAZON_FWDS] == [
'""'
]:
updated_config[CONF_AMAZON_FWDS] = []
if version <= 5:
if CONF_VERIFY_SSL not in updated_config:
updated_config[CONF_VERIFY_SSL] = True
if version <= 6:
if CONF_IMAP_SECURITY not in updated_config:
updated_config[CONF_IMAP_SECURITY] = "SSL"
if version <= 7:
if CONF_AMAZON_DOMAIN not in updated_config:
updated_config[CONF_AMAZON_DOMAIN] = "amazon.com"
if version < 10:
# Add default for image storage config
if CONF_STORAGE not in updated_config:
updated_config[CONF_STORAGE] = "custom_components/mail_and_packages/images/"
if CONF_PATH not in updated_config:
updated_config[CONF_PATH] = "custom_components/mail_and_packages/images/"
if updated_config != config_entry.data:
hass.config_entries.async_update_entry(
config_entry, data=updated_config, version=new_version
)
_LOGGER.debug("Migration complete to version %s", new_version)
return True
class MailDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching mail data."""
def __init__(self, hass, config):
"""Initialize."""
self.interval = timedelta(minutes=config.get(CONF_SCAN_INTERVAL))
self.name = f"Mail and Packages ({config.get(CONF_HOST)})"
self.timeout = config.get(CONF_IMAP_TIMEOUT)
self.config = config
self.hass = hass
self._data = {}
_LOGGER.debug("Data will be update every %s", self.interval)
super().__init__(hass, _LOGGER, name=self.name, update_interval=self.interval)
async def _async_update_data(self):
"""Fetch data."""
async with asyncio.timeout(self.timeout):
try:
data = await self.hass.async_add_executor_job(
process_emails, self.hass, self.config
)
except Exception as error:
_LOGGER.error("Problem updating sensors: %s", error)
raise UpdateFailed(error) from error
if data:
self._data = data
await self._binary_sensor_update()
return self._data
async def _binary_sensor_update(self):
"""Update binary sensor states."""
attributes = (ATTR_IMAGE_NAME, ATTR_IMAGE_PATH)
if set(attributes).issubset(self._data.keys()):
image = self._data[ATTR_IMAGE_NAME]
path = default_image_path(self.hass, self.config)
usps_image = f"{path}/{image}"
usps_none = f"{os.path.dirname(__file__)}/mail_none.gif"
usps_check = os.path.exists(usps_image)
_LOGGER.debug("USPS Check: %s", usps_check)
if usps_check:
image_hash = await self.hass.async_add_executor_job(
hash_file, usps_image
)
none_hash = await self.hass.async_add_executor_job(hash_file, usps_none)
_LOGGER.debug("USPS Image hash: %s", image_hash)
_LOGGER.debug("USPS None hash: %s", none_hash)
if image_hash != none_hash:
self._data["usps_update"] = True
else:
self._data["usps_update"] = False
attributes = (ATTR_AMAZON_IMAGE, ATTR_IMAGE_PATH)
if set(attributes).issubset(self._data.keys()):
image = self._data[ATTR_AMAZON_IMAGE]
path = f"{default_image_path(self.hass, self.config)}/amazon/"
amazon_image = f"{path}{image}"
amazon_none = f"{os.path.dirname(__file__)}/no_deliveries.jpg"
amazon_check = os.path.exists(amazon_image)
_LOGGER.debug("Amazon Check: %s", amazon_check)
if amazon_check:
image_hash = await self.hass.async_add_executor_job(
hash_file, amazon_image
)
none_hash = await self.hass.async_add_executor_job(
hash_file, amazon_none
)
_LOGGER.debug("Amazon Image hash: %s", image_hash)
_LOGGER.debug("Amazon None hash: %s", none_hash)
if image_hash != none_hash:
self._data["amazon_update"] = True
else:
self._data["amazon_update"] = False
@@ -0,0 +1,84 @@
"""Binary sensors for Mail and Packages."""
import logging
from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from .const import BINARY_SENSORS, COORDINATOR, DOMAIN, VERSION
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass, entry, async_add_devices):
"""Initialize binary_sensor platform."""
coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR]
binary_sensors = []
# pylint: disable=unused-variable
for variable, value in BINARY_SENSORS.items():
binary_sensors.append(PackagesBinarySensor(value, coordinator, entry))
async_add_devices(binary_sensors, False)
class PackagesBinarySensor(CoordinatorEntity, BinarySensorEntity):
"""Implementation of an Mail and Packages binary sensor."""
def __init__(
self,
sensor_description: BinarySensorEntityDescription,
coordinator: DataUpdateCoordinator,
config: ConfigEntry,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.coordinator = coordinator
self._config = config
self.entity_description = sensor_description
self._name = sensor_description.name
self._type = sensor_description.key
self._unique_id = config.entry_id
self._host = config.data[CONF_HOST]
self._attr_name = f"{self._name}"
self._attr_unique_id = f"{self._host}_{self._name}_{self._unique_id}"
@property
def device_info(self) -> dict:
"""Return device information about the mailbox."""
return {
"connections": {(DOMAIN, self._unique_id)},
"name": self._host,
"manufacturer": "IMAP E-Mail",
"sw_version": VERSION,
}
@property
def should_poll(self) -> bool:
"""No need to poll. Coordinator notifies entity of updates."""
return False
@property
def available(self) -> bool:
"""Return if entity is available."""
return self.coordinator.last_update_success
@property
def is_on(self) -> bool:
"""Return True if the image is updated."""
if self._type in self.coordinator.data.keys():
_LOGGER.debug(
"binary_sensor: %s value: %s",
self._type,
self.coordinator.data[self._type],
)
return bool(self.coordinator.data[self._type])
return False
@@ -0,0 +1,215 @@
"""Camera that loads a picture from a local file."""
from __future__ import annotations
import logging
import os
import voluptuous as vol
from homeassistant.components.camera import Camera
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST
from homeassistant.core import ServiceCall
from .const import (
ATTR_AMAZON_IMAGE,
ATTR_IMAGE_NAME,
ATTR_IMAGE_PATH,
CAMERA,
CAMERA_DATA,
CONF_CUSTOM_IMG,
CONF_CUSTOM_IMG_FILE,
COORDINATOR,
DOMAIN,
SENSOR_NAME,
VERSION,
)
SERVICE_UPDATE_IMAGE = "update_image"
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass, config, async_add_entities):
"""Set up the Camera that works with local files."""
if CAMERA not in hass.data[DOMAIN][config.entry_id]:
hass.data[DOMAIN][config.entry_id][CAMERA] = []
coordinator = hass.data[DOMAIN][config.entry_id][COORDINATOR]
camera = []
if not config.data.get(CONF_CUSTOM_IMG):
file_path = f"{os.path.dirname(__file__)}/mail_none.gif"
else:
file_path = config.data.get(CONF_CUSTOM_IMG_FILE)
for variable in CAMERA_DATA:
temp_cam = MailCam(hass, variable, config, coordinator, file_path)
camera.append(temp_cam)
hass.data[DOMAIN][config.entry_id][CAMERA].append(temp_cam)
async def _update_image(service: ServiceCall) -> None:
"""Refresh camera image."""
_LOGGER.debug("Updating image: %s", service)
cameras = hass.data[DOMAIN][config.entry_id][CAMERA]
entity_id = None
if ATTR_ENTITY_ID in service.data.keys():
entity_id = service.data[ATTR_ENTITY_ID]
# Update all cameras if no entity_id
if entity_id is None:
for cam in cameras:
cam.update_file_path()
else:
for cam in cameras:
if cam.entity_id in entity_id:
cam.update_file_path()
return True
hass.services.async_register(
DOMAIN,
SERVICE_UPDATE_IMAGE,
_update_image,
schema=vol.Schema(
{
vol.Optional(ATTR_ENTITY_ID): vol.Coerce(str),
}
),
)
async_add_entities(camera)
class MailCam(Camera):
"""Representation of a local file camera."""
def __init__(
self,
hass,
name: str,
config: ConfigEntry,
coordinator,
file_path: str,
) -> None:
"""Initialize Local File Camera component."""
super().__init__()
self.hass = hass
self._name = CAMERA_DATA[name][SENSOR_NAME]
self._type = name
self.check_file_path_access(file_path)
self._file_path = file_path
self._coordinator = coordinator
self._host = config.data.get(CONF_HOST)
self._unique_id = config.entry_id
self._no_mail = (
None
if not config.data.get(CONF_CUSTOM_IMG)
else config.data.get(CONF_CUSTOM_IMG_FILE)
)
async def async_camera_image(
self, width: int | None = None, height: int | None = None
) -> bytes | None:
"""Return image response."""
try:
file = await self.hass.async_add_executor_job(open, self._file_path, "rb")
return file.read()
except FileNotFoundError:
_LOGGER.info(
"Could not read camera %s image from file: %s",
self._name,
self._file_path,
)
def check_file_path_access(self, file_path: str) -> None:
"""Check that filepath given is readable."""
if not os.access(file_path, os.R_OK):
_LOGGER.info(
"Could not read camera %s image from file: %s", self._name, file_path
)
def update_file_path(self) -> None:
"""Update the file_path."""
_LOGGER.debug("Camera Update: %s", self._type)
_LOGGER.debug("Custom No Mail: %s", self._no_mail)
file_path = None
if not self._coordinator.last_update_success:
_LOGGER.debug("Update to update camera image. Unavailable.")
return
if self._coordinator.data is None:
_LOGGER.debug("Unable to update camera image, no data.")
return
if self._type == "usps_camera":
# Update camera image for USPS informed delivery images
file_path = f"{os.path.dirname(__file__)}/mail_none.gif"
s1 = set([ATTR_IMAGE_NAME, ATTR_IMAGE_PATH])
if s1.issubset(self._coordinator.data.keys()):
image = self._coordinator.data[ATTR_IMAGE_NAME]
path = self._coordinator.data[ATTR_IMAGE_PATH]
file_path = f"{self.hass.config.path()}/{path}{image}"
else:
if self._no_mail:
file_path = self._no_mail
elif self._type == "amazon_camera":
# Update camera image for Amazon deliveries
file_path = f"{os.path.dirname(__file__)}/no_deliveries.jpg"
s1 = set([ATTR_AMAZON_IMAGE, ATTR_IMAGE_PATH])
if s1.issubset(self._coordinator.data.keys()):
image = self._coordinator.data[ATTR_AMAZON_IMAGE]
path = f"{self._coordinator.data[ATTR_IMAGE_PATH]}amazon/"
file_path = f"{self.hass.config.path()}/{path}{image}"
self.check_file_path_access(file_path)
self._file_path = file_path
self.schedule_update_ha_state()
async def async_on_demand_update(self):
"""Update state."""
self.async_schedule_update_ha_state(True)
@property
def device_info(self) -> dict:
"""Return device information about the mailbox."""
return {
"connections": {(DOMAIN, self._unique_id)},
"name": self._host,
"manufacturer": "IMAP E-Mail",
"sw_version": VERSION,
}
@property
def unique_id(self) -> str:
"""Return a unique, Home Assistant friendly identifier for this entity."""
return f"{self._host}_{self._name}_{self._unique_id}"
@property
def name(self):
"""Return the name of this camera."""
return self._name
@property
def extra_state_attributes(self):
"""Return the camera state attributes."""
return {"file_path": self._file_path}
@property
def should_poll(self) -> bool:
"""Return True if entity has to be polled for state.
False if entity pushes its state to HA.
"""
return True
async def async_update(self):
"""Update camera entity and refresh attributes."""
self.update_file_path()
@property
def available(self) -> bool:
"""Return if entity is available."""
return self._coordinator.last_update_success
@@ -0,0 +1,619 @@
"""Adds config flow for Mail and Packages."""
import logging
from os import path
from typing import Any
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_RESOURCES,
CONF_USERNAME,
)
from .const import (
CONF_ALLOW_EXTERNAL,
CONF_AMAZON_DAYS,
CONF_AMAZON_DOMAIN,
CONF_AMAZON_FWDS,
CONF_CUSTOM_IMG,
CONF_CUSTOM_IMG_FILE,
CONF_DURATION,
CONF_FOLDER,
CONF_GENERATE_MP4,
CONF_IMAGE_SECURITY,
CONF_IMAP_SECURITY,
CONF_IMAP_TIMEOUT,
CONF_PATH,
CONF_SCAN_INTERVAL,
CONF_STORAGE,
CONF_VERIFY_SSL,
CONFIG_VER,
DEFAULT_ALLOW_EXTERNAL,
DEFAULT_AMAZON_DAYS,
DEFAULT_AMAZON_DOMAIN,
DEFAULT_AMAZON_FWDS,
DEFAULT_CUSTOM_IMG,
DEFAULT_CUSTOM_IMG_FILE,
DEFAULT_FOLDER,
DEFAULT_GIF_DURATION,
DEFAULT_IMAGE_SECURITY,
DEFAULT_IMAP_TIMEOUT,
DEFAULT_PATH,
DEFAULT_PORT,
DEFAULT_SCAN_INTERVAL,
DEFAULT_STORAGE,
DOMAIN,
)
from .helpers import _check_ffmpeg, _test_login, get_resources, login
ERROR_MAILBOX_FAIL = "Problem getting mailbox listing using 'INBOX' message"
IMAP_SECURITY = ["none", "startTLS", "SSL"]
AMAZON_SENSORS = ["amazon_packages", "amazon_delivered", "amazon_exception"]
_LOGGER = logging.getLogger(__name__)
AMAZON_EMAIL_ERROR = (
"Amazon domain found in email: %s, this may cause errors when searching emails."
)
async def _check_amazon_forwards(forwards: str, domain: str) -> tuple:
"""Validate and format amazon forward emails for user input.
Returns tuple: dict of errors, list of email addresses
"""
emails = forwards.split(",")
errors = []
# Validate each email address
for email in emails:
email = email.strip()
if "@" in email:
# Check for amazon domains
if f"@{domain}" in email:
_LOGGER.error(
AMAZON_EMAIL_ERROR,
email,
)
# No forwards
elif forwards in ["", "(none)", '""']:
forwards = []
else:
_LOGGER.error("Missing '@' in email address: %s", email)
errors.append("invalid_email_format")
if len(errors) == 0:
errors.append("ok")
return errors, forwards
async def _validate_user_input(user_input: dict) -> tuple:
"""Valididate user input from config flow.
Returns tuple with error messages and modified user_input
"""
errors = {}
# Validate amazon forwarding email addresses
if CONF_AMAZON_FWDS in user_input:
if isinstance(user_input[CONF_AMAZON_FWDS], str):
status, amazon_list = await _check_amazon_forwards(
user_input[CONF_AMAZON_FWDS], user_input[CONF_AMAZON_DOMAIN]
)
if status[0] == "ok":
user_input[CONF_AMAZON_FWDS] = amazon_list
else:
user_input[CONF_AMAZON_FWDS] = amazon_list
errors[CONF_AMAZON_FWDS] = status[0]
# Check for ffmpeg if option enabled
if user_input[CONF_GENERATE_MP4]:
valid = await _check_ffmpeg()
else:
valid = True
if not valid:
errors[CONF_GENERATE_MP4] = "ffmpeg_not_found"
# validate custom file exists
if user_input[CONF_CUSTOM_IMG] and CONF_CUSTOM_IMG_FILE in user_input:
valid = path.isfile(user_input[CONF_CUSTOM_IMG_FILE])
else:
valid = True
if not valid:
errors[CONF_CUSTOM_IMG_FILE] = "file_not_found"
# validate path exists
if CONF_STORAGE in user_input:
valid = path.exists(user_input[CONF_STORAGE])
else:
valid = True
if not valid:
errors[CONF_STORAGE] = "path_not_found"
return errors, user_input
def _get_mailboxes(
host: str, port: int, user: str, pwd: str, security: str, verify: bool
) -> list:
"""Get list of mailbox folders from mail server."""
account = login(host, port, user, pwd, security, verify)
status, folderlist = account.list()
mailboxes = []
if status != "OK":
_LOGGER.error("Error listing mailboxes ... using default")
mailboxes.append(DEFAULT_FOLDER)
else:
try:
for i in folderlist:
mailboxes.append(i.decode().split(' "/" ')[1])
except IndexError:
_LOGGER.error("Error creating folder array trying period")
try:
for i in folderlist:
mailboxes.append(i.decode().split(' "." ')[1])
except IndexError:
_LOGGER.error("Error creating folder array, using INBOX")
mailboxes.append(DEFAULT_FOLDER)
except Exception as err:
_LOGGER.error("%s: %s", ERROR_MAILBOX_FAIL, err)
mailboxes.append(DEFAULT_FOLDER)
return mailboxes
def _get_schema_step_1(user_input: list, default_dict: list) -> Any:
"""Get a schema using the default_dict as a backup."""
if user_input is None:
user_input = {}
def _get_default(key: str, fallback_default: Any = None) -> None:
"""Get default value for key."""
return user_input.get(key, default_dict.get(key, fallback_default))
return vol.Schema(
{
vol.Required(CONF_HOST, default=_get_default(CONF_HOST)): cv.string,
vol.Required(CONF_PORT, default=_get_default(CONF_PORT, 993)): cv.port,
vol.Required(CONF_USERNAME, default=_get_default(CONF_USERNAME)): cv.string,
vol.Required(CONF_PASSWORD, default=_get_default(CONF_PASSWORD)): cv.string,
vol.Required(
CONF_IMAP_SECURITY, default=_get_default(CONF_IMAP_SECURITY)
): vol.In(IMAP_SECURITY),
vol.Required(
CONF_VERIFY_SSL, default=_get_default(CONF_VERIFY_SSL)
): cv.boolean,
}
)
def _get_schema_step_2(data: list, user_input: list, default_dict: list) -> Any:
"""Get a schema using the default_dict as a backup."""
if user_input is None:
user_input = {}
def _get_default(key: str, fallback_default: Any = None) -> None:
"""Get default value for key."""
return user_input.get(key, default_dict.get(key, fallback_default))
return vol.Schema(
{
vol.Required(CONF_FOLDER, default=_get_default(CONF_FOLDER)): vol.In(
_get_mailboxes(
data[CONF_HOST],
data[CONF_PORT],
data[CONF_USERNAME],
data[CONF_PASSWORD],
data[CONF_IMAP_SECURITY],
data[CONF_VERIFY_SSL],
)
),
vol.Required(
CONF_RESOURCES, default=_get_default(CONF_RESOURCES)
): cv.multi_select(get_resources()),
vol.Optional(
CONF_SCAN_INTERVAL, default=_get_default(CONF_SCAN_INTERVAL)
): vol.All(vol.Coerce(int), vol.Range(min=5)),
vol.Optional(
CONF_IMAP_TIMEOUT, default=_get_default(CONF_IMAP_TIMEOUT)
): vol.All(vol.Coerce(int), vol.Range(min=10)),
vol.Optional(
CONF_DURATION, default=_get_default(CONF_DURATION)
): vol.Coerce(int),
vol.Optional(
CONF_GENERATE_MP4, default=_get_default(CONF_GENERATE_MP4)
): cv.boolean,
vol.Optional(
CONF_ALLOW_EXTERNAL, default=_get_default(CONF_ALLOW_EXTERNAL)
): cv.boolean,
vol.Optional(
CONF_CUSTOM_IMG, default=_get_default(CONF_CUSTOM_IMG)
): cv.boolean,
}
)
def _get_schema_step_3(user_input: list, default_dict: list) -> Any:
"""Get a schema using the default_dict as a backup."""
if user_input is None:
user_input = {}
def _get_default(key: str, fallback_default: Any = None) -> None:
"""Get default value for key."""
return user_input.get(key, default_dict.get(key, fallback_default))
return vol.Schema(
{
vol.Optional(
CONF_CUSTOM_IMG_FILE,
default=_get_default(CONF_CUSTOM_IMG_FILE, DEFAULT_CUSTOM_IMG_FILE),
): cv.string,
}
)
def _get_schema_step_amazon(user_input: list, default_dict: list) -> Any:
"""Get a schema using the default_dict as a backup."""
if user_input is None:
user_input = {}
def _get_default(key: str, fallback_default: Any = None) -> None:
"""Get default value for key."""
return user_input.get(key, default_dict.get(key, fallback_default))
return vol.Schema(
{
vol.Required(
CONF_AMAZON_DOMAIN, default=_get_default(CONF_AMAZON_DOMAIN)
): cv.string,
vol.Optional(
CONF_AMAZON_FWDS, default=_get_default(CONF_AMAZON_FWDS)
): cv.string,
vol.Optional(CONF_AMAZON_DAYS, default=_get_default(CONF_AMAZON_DAYS)): int,
}
)
def _get_schema_step_storage(user_input: list, default_dict: list) -> Any:
"""Get a schema using the default_dict as a backup."""
if user_input is None:
user_input = {}
def _get_default(key: str, fallback_default: Any = None) -> None:
"""Get default value for key."""
return user_input.get(key, default_dict.get(key, fallback_default))
return vol.Schema(
{
vol.Required(
CONF_STORAGE, default=_get_default(CONF_STORAGE, DEFAULT_STORAGE)
): cv.string,
}
)
@config_entries.HANDLERS.register(DOMAIN)
class MailAndPackagesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Config flow for Mail and Packages."""
VERSION = CONFIG_VER
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
def __init__(self):
"""Initialize."""
self._entry = {}
self._data = {}
self._errors = {}
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
self._errors = {}
if user_input is not None:
self._data.update(user_input)
valid = await _test_login(
user_input[CONF_HOST],
user_input[CONF_PORT],
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
user_input[CONF_IMAP_SECURITY],
user_input[CONF_VERIFY_SSL],
)
if not valid:
self._errors["base"] = "communication"
else:
return await self.async_step_config_2()
return await self._show_config_form(user_input)
return await self._show_config_form(user_input)
async def _show_config_form(self, user_input):
"""Show the configuration form to edit configuration data."""
# Defaults
defaults = {
CONF_PORT: DEFAULT_PORT,
CONF_IMAP_SECURITY: "SSL",
CONF_VERIFY_SSL: True,
}
return self.async_show_form(
step_id="user",
data_schema=_get_schema_step_1(user_input, defaults),
errors=self._errors,
)
async def async_step_config_2(self, user_input=None):
"""Configure form step 2."""
self._errors = {}
if user_input is not None:
self._errors, user_input = await _validate_user_input(user_input)
self._data.update(user_input)
_LOGGER.debug("RESOURCES: %s", self._data[CONF_RESOURCES])
if len(self._errors) == 0:
if any(
sensor in self._data[CONF_RESOURCES] for sensor in AMAZON_SENSORS
):
return await self.async_step_config_amazon()
if self._data[CONF_CUSTOM_IMG]:
return await self.async_step_config_3()
return self.async_create_entry(
title=self._data[CONF_HOST], data=self._data
)
return await self._show_config_2(user_input)
return await self._show_config_2(user_input)
async def _show_config_2(self, user_input):
"""Step 2 setup."""
# Defaults
defaults = {
CONF_FOLDER: DEFAULT_FOLDER,
CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL,
CONF_PATH: self.hass.config.path() + DEFAULT_PATH,
CONF_DURATION: DEFAULT_GIF_DURATION,
CONF_IMAGE_SECURITY: DEFAULT_IMAGE_SECURITY,
CONF_IMAP_TIMEOUT: DEFAULT_IMAP_TIMEOUT,
CONF_GENERATE_MP4: False,
CONF_ALLOW_EXTERNAL: DEFAULT_ALLOW_EXTERNAL,
CONF_CUSTOM_IMG: DEFAULT_CUSTOM_IMG,
}
return self.async_show_form(
step_id="config_2",
data_schema=_get_schema_step_2(self._data, user_input, defaults),
errors=self._errors,
)
async def async_step_config_3(self, user_input=None):
"""Configure form step 2."""
self._errors = {}
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(self._data)
if len(self._errors) == 0:
return await self.async_step_config_storage()
return await self._show_config_3(user_input)
return await self._show_config_3(user_input)
async def _show_config_3(self, user_input):
"""Step 3 setup."""
# Defaults
defaults = {
CONF_CUSTOM_IMG_FILE: DEFAULT_CUSTOM_IMG_FILE,
}
return self.async_show_form(
step_id="config_3",
data_schema=_get_schema_step_3(user_input, defaults),
errors=self._errors,
)
async def async_step_config_amazon(self, user_input=None):
"""Configure form step amazon."""
self._errors = {}
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(self._data)
if len(self._errors) == 0:
if self._data[CONF_CUSTOM_IMG]:
return await self.async_step_config_3()
return await self.async_step_config_storage()
return await self._show_config_amazon(user_input)
return await self._show_config_amazon(user_input)
async def _show_config_amazon(self, user_input):
"""Step 3 setup."""
# Defaults
defaults = {
CONF_AMAZON_DOMAIN: DEFAULT_AMAZON_DOMAIN,
CONF_AMAZON_FWDS: DEFAULT_AMAZON_FWDS,
CONF_AMAZON_DAYS: DEFAULT_AMAZON_DAYS,
}
return self.async_show_form(
step_id="config_amazon",
data_schema=_get_schema_step_amazon(user_input, defaults),
errors=self._errors,
)
async def async_step_config_storage(self, user_input=None):
"""Configure form step storage."""
self._errors = {}
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(self._data)
if len(self._errors) == 0:
return self.async_create_entry(
title=self._data[CONF_HOST], data=self._data
)
return await self._show_config_storage(user_input)
return await self._show_config_storage(user_input)
async def _show_config_storage(self, user_input):
"""Step 3 setup."""
# Defaults
defaults = {
CONF_STORAGE: DEFAULT_STORAGE,
}
return self.async_show_form(
step_id="config_storage",
data_schema=_get_schema_step_storage(user_input, defaults),
errors=self._errors,
)
async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None):
"""Add reconfigure step to allow to reconfigure a config entry."""
self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
assert self._entry
self._data = dict(self._entry.data)
self._errors = {}
if user_input is not None:
self._data.update(user_input)
valid = await _test_login(
user_input[CONF_HOST],
user_input[CONF_PORT],
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
user_input[CONF_IMAP_SECURITY],
user_input[CONF_VERIFY_SSL],
)
if not valid:
self._errors["base"] = "communication"
else:
return await self.async_step_reconfig_2()
return await self._show_reconfig_form(user_input)
return await self._show_reconfig_form(user_input)
async def _show_reconfig_form(self, user_input):
"""Show the configuration form to edit configuration data."""
return self.async_show_form(
step_id="reconfigure",
data_schema=_get_schema_step_1(user_input, self._data),
errors=self._errors,
)
async def async_step_reconfig_2(self, user_input=None):
"""Configure form step 2."""
self._errors = {}
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(user_input)
if len(self._errors) == 0:
if any(
sensor in self._data[CONF_RESOURCES] for sensor in AMAZON_SENSORS
):
return await self.async_step_reconfig_amazon()
if self._data[CONF_CUSTOM_IMG]:
return await self.async_step_reconfig_3()
return await self.async_step_reconfig_storage()
return await self._show_reconfig_2(user_input)
return await self._show_reconfig_2(user_input)
async def _show_reconfig_2(self, user_input):
"""Step 2 setup."""
if self._data[CONF_AMAZON_FWDS] == []:
self._data[CONF_AMAZON_FWDS] = "(none)"
return self.async_show_form(
step_id="reconfig_2",
data_schema=_get_schema_step_2(self._data, user_input, self._data),
errors=self._errors,
)
async def async_step_reconfig_3(self, user_input=None):
"""Configure form step 2."""
self._errors = {}
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(self._data)
if len(self._errors) == 0:
return await self.async_step_reconfig_storage()
return await self._show_reconfig_3(user_input)
return await self._show_reconfig_3(user_input)
async def _show_reconfig_3(self, user_input):
"""Step 3 setup."""
# Defaults
defaults = {
CONF_CUSTOM_IMG_FILE: DEFAULT_CUSTOM_IMG_FILE,
}
return self.async_show_form(
step_id="reconfig_3",
data_schema=_get_schema_step_3(user_input, defaults),
errors=self._errors,
)
async def async_step_reconfig_amazon(self, user_input=None):
"""Configure form step amazon."""
self._errors = {}
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(self._data)
if len(self._errors) == 0:
if self._data[CONF_CUSTOM_IMG]:
return await self.async_step_reconfig_3()
return await self.async_step_reconfig_storage()
return await self._show_reconfig_amazon(user_input)
return await self._show_reconfig_amazon(user_input)
async def _show_reconfig_amazon(self, user_input):
"""Step 3 setup."""
return self.async_show_form(
step_id="reconfig_amazon",
data_schema=_get_schema_step_amazon(user_input, self._data),
errors=self._errors,
)
async def async_step_reconfig_storage(self, user_input=None):
"""Configure form step storage."""
self._errors = {}
if user_input is not None:
self._data.update(user_input)
self._errors, user_input = await _validate_user_input(self._data)
if len(self._errors) == 0:
self.hass.config_entries.async_update_entry(
self._entry, data=self._data
)
await self.hass.config_entries.async_reload(self._entry.entry_id)
_LOGGER.debug("%s reconfigured.", DOMAIN)
return self.async_abort(reason="reconfigure_successful")
return await self._show_reconfig_storage(user_input)
return await self._show_reconfig_storage(user_input)
async def _show_reconfig_storage(self, user_input):
"""Step 3 setup."""
return self.async_show_form(
step_id="reconfig_storage",
data_schema=_get_schema_step_storage(user_input, self._data),
errors=self._errors,
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
"""Provide diagnostics for Mail and Packages."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
from .const import CONF_AMAZON_FWDS, COORDINATOR, DOMAIN
_LOGGER = logging.getLogger(__name__)
REDACT_KEYS = {CONF_PASSWORD, CONF_USERNAME, CONF_AMAZON_FWDS}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry # pylint: disable=unused-argument
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
diag: dict[str, Any] = {}
diag["config"] = config_entry.as_dict()
return async_redact_data(diag, REDACT_KEYS)
async def async_get_device_diagnostics(
hass: HomeAssistant,
config_entry: ConfigEntry,
device: DeviceEntry, # pylint: disable=unused-argument
) -> dict[str, Any]:
"""Return diagnostics for a device."""
coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR]
for variable in coordinator.data:
if "tracking" in variable or "order" in variable:
_LOGGER.debug("Atempting to add: %s for redaction.", variable)
REDACT_KEYS.add(variable)
_LOGGER.debug("Redacted keys: %s", REDACT_KEYS)
return async_redact_data(coordinator.data, REDACT_KEYS)
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

@@ -0,0 +1,228 @@
"""Based on @skalavala work.
https://blog.kalavala.net/usps/homeassistant/mqtt/2018/01/12/usps.html
Configuration code contribution from @firstof9 https://github.com/firstof9/
"""
import datetime
import logging
from datetime import timezone
from typing import Any, Optional
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_RESOURCES
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
AMAZON_DELIVERED,
AMAZON_EXCEPTION,
AMAZON_EXCEPTION_ORDER,
AMAZON_ORDER,
ATTR_IMAGE,
ATTR_IMAGE_NAME,
ATTR_IMAGE_PATH,
ATTR_ORDER,
ATTR_TRACKING_NUM,
CONF_PATH,
COORDINATOR,
DOMAIN,
IMAGE_SENSORS,
SENSOR_TYPES,
VERSION,
)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass, entry, async_add_entities):
"""Set up the sensor entities."""
coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR]
sensors = []
resources = entry.data[CONF_RESOURCES]
for variable in resources:
if variable in SENSOR_TYPES:
sensors.append(PackagesSensor(entry, SENSOR_TYPES[variable], coordinator))
for variable, value in IMAGE_SENSORS.items():
sensors.append(ImagePathSensors(hass, entry, value, coordinator))
async_add_entities(sensors, False)
class PackagesSensor(CoordinatorEntity, SensorEntity):
"""Representation of a sensor."""
def __init__(
self,
config: ConfigEntry,
sensor_description: SensorEntityDescription,
coordinator: str,
):
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = sensor_description
self.coordinator = coordinator
self._config = config
self._name = sensor_description.name
self.type = sensor_description.key
self._host = config.data[CONF_HOST]
self._unique_id = self._config.entry_id
self.data = self.coordinator.data
@property
def device_info(self) -> dict:
"""Return device information about the mailbox."""
return {
"connections": {(DOMAIN, self._unique_id)},
"name": self._host,
"manufacturer": "IMAP E-Mail",
"sw_version": VERSION,
}
@property
def unique_id(self) -> str:
"""Return a unique, Home Assistant friendly identifier for this entity."""
return f"{self._host}_{self._name}_{self._unique_id}"
@property
def name(self) -> str:
"""Return the name of the sensor."""
return self._name
@property
def native_value(self) -> Any:
"""Return the state of the sensor."""
value = None
if self.type in self.coordinator.data.keys():
value = self.coordinator.data[self.type]
if self.type == "mail_updated":
value = datetime.datetime.now(timezone.utc)
else:
value = None
return value
@property
def should_poll(self) -> bool:
"""No need to poll. Coordinator notifies entity of updates."""
return False
@property
def available(self) -> bool:
"""Return if entity is available."""
return self.coordinator.last_update_success
@property
def extra_state_attributes(self) -> Optional[str]:
"""Return device specific state attributes."""
attr = {}
tracking = f"{'_'.join(self.type.split('_')[:-1])}_tracking"
data = self.coordinator.data
# Catch no data entries
if self.data is None:
return attr
if "Amazon" in self._name:
if self._name == AMAZON_EXCEPTION and AMAZON_EXCEPTION_ORDER in data.keys():
attr[ATTR_ORDER] = data[AMAZON_EXCEPTION_ORDER]
elif self._name == AMAZON_DELIVERED:
attr[ATTR_ORDER] = data[AMAZON_DELIVERED]
elif AMAZON_ORDER in data.keys():
attr[ATTR_ORDER] = data[AMAZON_ORDER]
elif self._name == "Mail USPS Mail" and ATTR_IMAGE_NAME in data.keys():
attr[ATTR_IMAGE] = data[ATTR_IMAGE_NAME]
elif (
any(sensor in self.type for sensor in ["_delivering", "_delivered"])
and tracking in data.keys()
):
attr[ATTR_TRACKING_NUM] = data[tracking]
# TODO: Add Tracking URL when applicable
return attr
class ImagePathSensors(CoordinatorEntity, SensorEntity):
"""Representation of a sensor."""
def __init__(
self,
hass: HomeAssistant,
config: ConfigEntry,
sensor_description: SensorEntityDescription,
coordinator: str,
):
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = sensor_description
self.hass = hass
self.coordinator = coordinator
self._config = config
self._name = sensor_description.name
self.type = sensor_description.key
self._host = config.data[CONF_HOST]
self._unique_id = self._config.entry_id
@property
def device_info(self) -> dict:
"""Return device information about the mailbox."""
return {
"connections": {(DOMAIN, self._unique_id)},
"name": self._host,
"manufacturer": "IMAP E-Mail",
"sw_version": VERSION,
}
@property
def unique_id(self) -> str:
"""Return a unique, Home Assistant friendly identifier for this entity."""
return f"{self._host}_{self._name}_{self._unique_id}"
@property
def name(self) -> str:
"""Return the name of the sensor."""
return self._name
@property
def native_value(self) -> Optional[str]:
"""Return the state of the sensor."""
image = ""
the_path = None
if ATTR_IMAGE_NAME in self.coordinator.data.keys():
image = self.coordinator.data[ATTR_IMAGE_NAME]
if ATTR_IMAGE_PATH in self.coordinator.data.keys():
path = self.coordinator.data[ATTR_IMAGE_PATH]
else:
path = self._config.data[CONF_PATH]
if self.type == "usps_mail_image_system_path":
_LOGGER.debug("Updating system image path to: %s", path)
the_path = f"{self.hass.config.path()}/{path}{image}"
elif self.type == "usps_mail_image_url":
if (
self.hass.config.external_url is None
and self.hass.config.internal_url is None
):
the_path = None
elif self.hass.config.external_url is None:
_LOGGER.debug("External URL not set in configuration.")
url = self.hass.config.internal_url
the_path = f"{url.rstrip('/')}/local/mail_and_packages/{image}"
else:
url = self.hass.config.external_url
the_path = f"{url.rstrip('/')}/local/mail_and_packages/{image}"
return the_path
@property
def should_poll(self) -> bool:
"""No need to poll. Coordinator notifies entity of updates."""
return False
@property
def available(self) -> bool:
"""Return if entity is available."""
return self.coordinator.last_update_success
@@ -0,0 +1,13 @@
update_image:
name: Refresh the Mail Camera(s)
description: Refreshes the Mail Camera specified or all of them at once. Leave blank to refresh them all.
fields:
entity_id:
name: Camera Entity
description: The camera entity to refresh.
example: camera.mail_usps_camera
required: false
selector:
entity:
domain: camera
integration: mail_and_packages
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 B