Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions homeassistant/components/alexa_devices/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,6 @@ async def async_setup_entry(
"detectionState",
)

async_add_entities(
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
for sensor_desc in BINARY_SENSORS
for serial_num in coordinator.data
if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key)
)

known_devices: set[str] = set()

def _check_device() -> None:
Expand Down
78 changes: 78 additions & 0 deletions homeassistant/components/imap/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
from __future__ import annotations

import asyncio
from email.message import Message
import logging
from typing import Any

from aioimaplib import IMAP4_SSL, AioImapException, Response
import voluptuous as vol
Expand Down Expand Up @@ -33,13 +35,15 @@
ImapPollingDataUpdateCoordinator,
ImapPushDataUpdateCoordinator,
connect_to_server,
get_parts,
)
from .errors import InvalidAuth, InvalidFolder

PLATFORMS: list[Platform] = [Platform.SENSOR]

CONF_ENTRY = "entry"
CONF_SEEN = "seen"
CONF_PART = "part"
CONF_UID = "uid"
CONF_TARGET_FOLDER = "target_folder"

Expand All @@ -64,6 +68,11 @@
)
SERVICE_DELETE_SCHEMA = _SERVICE_UID_SCHEMA
SERVICE_FETCH_TEXT_SCHEMA = _SERVICE_UID_SCHEMA
SERVICE_FETCH_PART_SCHEMA = _SERVICE_UID_SCHEMA.extend(
{
vol.Required(CONF_PART): cv.string,
}
)

type ImapConfigEntry = ConfigEntry[ImapDataUpdateCoordinator]

Expand Down Expand Up @@ -216,12 +225,14 @@ async def async_fetch(call: ServiceCall) -> ServiceResponse:
translation_placeholders={"error": str(exc)},
) from exc
raise_on_error(response, "fetch_failed")
# Index 1 of of the response lines contains the bytearray with the message data
message = ImapMessage(response.lines[1])
await client.close()
return {
"text": message.text,
"sender": message.sender,
"subject": message.subject,
"parts": get_parts(message.email_message),
"uid": uid,
}

Expand All @@ -233,6 +244,73 @@ async def async_fetch(call: ServiceCall) -> ServiceResponse:
supports_response=SupportsResponse.ONLY,
)

async def async_fetch_part(call: ServiceCall) -> ServiceResponse:
"""Process fetch email part service and return content."""

@callback
def get_message_part(message: Message, part_key: str) -> Message:
part: Message | Any = message
for index in part_key.split(","):
sub_parts = part.get_payload()
try:
assert isinstance(sub_parts, list)
part = sub_parts[int(index)]
except (AssertionError, ValueError, IndexError) as exc:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_part_index",
) from exc

return part

entry_id: str = call.data[CONF_ENTRY]
uid: str = call.data[CONF_UID]
part_key: str = call.data[CONF_PART]
_LOGGER.debug(
"Fetch part %s for message %s. Entry: %s",
part_key,
uid,
entry_id,
)
client = await async_get_imap_client(hass, entry_id)
try:
response = await client.fetch(uid, "BODY.PEEK[]")
except (TimeoutError, AioImapException) as exc:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="imap_server_fail",
translation_placeholders={"error": str(exc)},
) from exc
raise_on_error(response, "fetch_failed")
# Index 1 of of the response lines contains the bytearray with the message data
message = ImapMessage(response.lines[1])
await client.close()
part_data = get_message_part(message.email_message, part_key)
part_data_content = part_data.get_payload(decode=False)
try:
assert isinstance(part_data_content, str)
except AssertionError as exc:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_part_index",
) from exc
return {
"part_data": part_data_content,
"content_type": part_data.get_content_type(),
"content_transfer_encoding": part_data.get("Content-Transfer-Encoding"),
"filename": part_data.get_filename(),
"part": part_key,
"uid": uid,
}

hass.services.async_register(
DOMAIN,
"fetch_part",
async_fetch_part,
SERVICE_FETCH_PART_SCHEMA,
supports_response=SupportsResponse.ONLY,
)

return True


Expand Down
25 changes: 24 additions & 1 deletion homeassistant/components/imap/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
CONF_VERIFY_SSL,
CONTENT_TYPE_TEXT_PLAIN,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryError,
Expand Down Expand Up @@ -209,6 +209,28 @@ def text(self) -> str:
return str(self.email_message.get_payload())


@callback
def get_parts(message: Message, prefix: str | None = None) -> dict[str, Any]:
"""Return information about the parts of a multipart message."""
parts: dict[str, Any] = {}
if not message.is_multipart():
return {}
for index, part in enumerate(message.get_payload(), 0):
if TYPE_CHECKING:
assert isinstance(part, Message)
key = f"{prefix},{index}" if prefix else f"{index}"
if part.is_multipart():
parts |= get_parts(part, key)
continue
parts[key] = {"content_type": part.get_content_type()}
if filename := part.get_filename():
parts[key]["filename"] = filename
if content_transfer_encoding := part.get("Content-Transfer-Encoding"):
parts[key]["content_transfer_encoding"] = content_transfer_encoding

return parts


class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
"""Base class for imap client."""

Expand Down Expand Up @@ -275,6 +297,7 @@ async def _async_process_event(self, last_message_uid: str) -> None:
"sender": message.sender,
"subject": message.subject,
"uid": last_message_uid,
"parts": get_parts(message.email_message),
}
data.update({key: getattr(message, key) for key in self._event_data_keys})
if self.custom_event_template is not None:
Expand Down
3 changes: 3 additions & 0 deletions homeassistant/components/imap/icons.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
},
"fetch": {
"service": "mdi:email-sync-outline"
},
"fetch_part": {
"service": "mdi:email-sync-outline"
}
}
}
19 changes: 19 additions & 0 deletions homeassistant/components/imap/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,22 @@ fetch:
example: "12"
selector:
text:

fetch_part:
fields:
entry:
required: true
selector:
config_entry:
integration: "imap"
uid:
required: true
example: "12"
selector:
text:

part:
required: true
example: "0,1"
selector:
text:
21 changes: 21 additions & 0 deletions homeassistant/components/imap/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@
"imap_server_fail": {
"message": "The IMAP server failed to connect: {error}."
},
"invalid_part_index": {
"message": "Invalid part index."
},
"seen_failed": {
"message": "Marking message as seen failed with \"{error}\"."
}
Expand Down Expand Up @@ -148,6 +151,24 @@
}
}
},
"fetch_part": {
"name": "Fetch message part",
"description": "Fetches a message part or attachment from an email message.",
"fields": {
"entry": {
"name": "[%key:component::imap::services::fetch::fields::entry::name%]",
"description": "[%key:component::imap::services::fetch::fields::entry::description%]"
},
"uid": {
"name": "[%key:component::imap::services::fetch::fields::uid::name%]",
"description": "[%key:component::imap::services::fetch::fields::uid::description%]"
},
"part": {
"name": "Part",
"description": "The message part index."
}
}
},
"seen": {
"name": "Mark message as seen",
"description": "Marks an email as seen.",
Expand Down
25 changes: 22 additions & 3 deletions homeassistant/components/portainer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@
from pyportainer import Portainer

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_VERIFY_SSL, Platform
from homeassistant.const import (
CONF_API_KEY,
CONF_API_TOKEN,
CONF_HOST,
CONF_URL,
CONF_VERIFY_SSL,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_create_clientsession

Expand All @@ -20,8 +27,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) ->
"""Set up Portainer from a config entry."""

client = Portainer(
api_url=entry.data[CONF_HOST],
api_key=entry.data[CONF_API_KEY],
api_url=entry.data[CONF_URL],
api_key=entry.data[CONF_API_TOKEN],
session=async_create_clientsession(
hass=hass, verify_ssl=entry.data[CONF_VERIFY_SSL]
),
Expand All @@ -39,3 +46,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)


async def async_migrate_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> bool:
"""Migrate old entry."""

if entry.version < 2:
data = dict(entry.data)
data[CONF_URL] = data.pop(CONF_HOST)
data[CONF_API_TOKEN] = data.pop(CONF_API_KEY)
hass.config_entries.async_update_entry(entry=entry, data=data, version=2)

return True
24 changes: 14 additions & 10 deletions homeassistant/components/portainer/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import voluptuous as vol

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_VERIFY_SSL
from homeassistant.const import CONF_API_TOKEN, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
Expand All @@ -24,8 +24,8 @@
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_API_KEY): str,
vol.Required(CONF_URL): str,
vol.Required(CONF_API_TOKEN): str,
vol.Optional(CONF_VERIFY_SSL, default=True): bool,
}
)
Expand All @@ -35,9 +35,11 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
"""Validate the user input allows us to connect."""

client = Portainer(
api_url=data[CONF_HOST],
api_key=data[CONF_API_KEY],
session=async_get_clientsession(hass=hass, verify_ssl=data[CONF_VERIFY_SSL]),
api_url=data[CONF_URL],
api_key=data[CONF_API_TOKEN],
session=async_get_clientsession(
hass=hass, verify_ssl=data.get(CONF_VERIFY_SSL, True)
),
)
try:
await client.get_endpoints()
Expand All @@ -48,19 +50,21 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
except PortainerTimeoutError as err:
raise PortainerTimeout from err

_LOGGER.debug("Connected to Portainer API: %s", data[CONF_HOST])
_LOGGER.debug("Connected to Portainer API: %s", data[CONF_URL])


class PortainerConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Portainer."""

VERSION = 2

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
try:
await _validate_input(self.hass, user_input)
except CannotConnect:
Expand All @@ -73,10 +77,10 @@ async def async_step_user(
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(user_input[CONF_API_KEY])
await self.async_set_unique_id(user_input[CONF_API_TOKEN])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_input[CONF_HOST], data=user_input
title=user_input[CONF_URL], data=user_input
)

return self.async_show_form(
Expand Down
4 changes: 2 additions & 2 deletions homeassistant/components/portainer/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from pyportainer.models.portainer import Endpoint

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
Expand Down Expand Up @@ -87,7 +87,7 @@ async def _async_setup(self) -> None:
async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]:
"""Fetch data from Portainer API."""
_LOGGER.debug(
"Fetching data from Portainer API: %s", self.config_entry.data[CONF_HOST]
"Fetching data from Portainer API: %s", self.config_entry.data[CONF_URL]
)

try:
Expand Down
Loading
Loading