Initial commit.
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 171 B |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
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
|
||||
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 171 B |