From 5834ad0c52dff965e7a3ea8f48f4be6d4e28741d Mon Sep 17 00:00:00 2001 From: LukasQ Date: Thu, 19 Mar 2026 10:25:28 +0100 Subject: [PATCH 01/40] Add Threema integration Add new integration for Threema Gateway messaging service. Supports E2E encrypted and simple text messaging, key pair generation, QR code identity verification, and reauthentication. Co-Authored-By: Claude Opus 4.6 (1M context) --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/threema/README.md | 37 ++ homeassistant/components/threema/__init__.py | 158 ++++++++ homeassistant/components/threema/client.py | 138 +++++++ .../components/threema/config_flow.py | 216 ++++++++++ homeassistant/components/threema/const.py | 8 + homeassistant/components/threema/icons.json | 7 + homeassistant/components/threema/image.py | 90 +++++ .../components/threema/manifest.json | 12 + .../components/threema/quality_scale.yaml | 89 ++++ .../components/threema/services.yaml | 16 + homeassistant/components/threema/strings.json | 117 ++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 6 + requirements_test_all.txt | 6 + tests/components/threema/__init__.py | 1 + tests/components/threema/conftest.py | 108 +++++ tests/components/threema/test_config_flow.py | 380 ++++++++++++++++++ tests/components/threema/test_image.py | 94 +++++ tests/components/threema/test_init.py | 305 ++++++++++++++ 23 files changed, 1808 insertions(+) create mode 100644 homeassistant/components/threema/README.md create mode 100644 homeassistant/components/threema/__init__.py create mode 100644 homeassistant/components/threema/client.py create mode 100644 homeassistant/components/threema/config_flow.py create mode 100644 homeassistant/components/threema/const.py create mode 100644 homeassistant/components/threema/icons.json create mode 100644 homeassistant/components/threema/image.py create mode 100644 homeassistant/components/threema/manifest.json create mode 100644 homeassistant/components/threema/quality_scale.yaml create mode 100644 homeassistant/components/threema/services.yaml create mode 100644 homeassistant/components/threema/strings.json create mode 100644 tests/components/threema/__init__.py create mode 100644 tests/components/threema/conftest.py create mode 100644 tests/components/threema/test_config_flow.py create mode 100644 tests/components/threema/test_image.py create mode 100644 tests/components/threema/test_init.py diff --git a/.strict-typing b/.strict-typing index 09954a3b27ccde..90f0ed784f3c7d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -549,6 +549,7 @@ homeassistant.components.telegram_bot.* homeassistant.components.teslemetry.* homeassistant.components.text.* homeassistant.components.thethingsnetwork.* +homeassistant.components.threema.* homeassistant.components.threshold.* homeassistant.components.tibber.* homeassistant.components.tile.* diff --git a/CODEOWNERS b/CODEOWNERS index e092a83b12bec0..5dc49c777f61c0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1719,6 +1719,8 @@ build.json @home-assistant/supervisor /tests/components/thethingsnetwork/ @angelnu /homeassistant/components/thread/ @home-assistant/core /tests/components/thread/ @home-assistant/core +/homeassistant/components/threema/ @LukasQ +/tests/components/threema/ @LukasQ /homeassistant/components/tibber/ @danielhiversen /tests/components/tibber/ @danielhiversen /homeassistant/components/tile/ @bachya diff --git a/homeassistant/components/threema/README.md b/homeassistant/components/threema/README.md new file mode 100644 index 00000000000000..1bbd99583e4d74 --- /dev/null +++ b/homeassistant/components/threema/README.md @@ -0,0 +1,37 @@ +# Threema Integration + +Threema Gateway integration for Home Assistant. Sends E2E encrypted or simple text messages via the [Threema Gateway](https://gateway.threema.ch/) service. + +User-facing documentation: https://www.home-assistant.io/integrations/threema + +## Architecture + +- **`__init__.py`** — Service registration (`threema.send_message`), config entry setup/unload, credential validation on startup +- **`client.py`** — `ThreemaAPIClient` wrapping the [threema.gateway SDK](https://github.com/threema-ch/threema-msgapi-sdk-python). Handles E2E (`TextMessage`) and simple (`SimpleTextMessage`) modes. Custom exceptions: `ThreemaAuthError`, `ThreemaConnectionError`, `ThreemaSendError` +- **`config_flow.py`** — Multi-step flow: setup type selection, optional key generation, credential entry with validation, reauthentication +- **`image.py`** — QR code image entity for gateway identity verification (encodes `3mid:,`) +- **`const.py`** — Domain and config key constants + +## Dependencies + +- `threema.gateway==8.0.0` — [Official Threema Gateway SDK](https://github.com/threema-ch/threema-msgapi-sdk-python) (MIT) +- `qrcode==8.2` — QR code generation for identity verification + +## Design Decisions + +- **No notify entity** — `NotifyEntity.async_send_message` only accepts `message` and `title`, no recipient parameter. Threema always requires an explicit recipient, so the custom `threema.send_message` service is the proper interface. +- **Encryption downgrade protection** — Reauth flow preserves existing private keys when the field is left empty, preventing silent downgrade from E2E to simple mode. +- **Recipient validation** — Service schema validates Threema IDs with `^[0-9A-Za-z]{8}$` regex and normalizes to uppercase. +- **Multiple entry guard** — Auto-select is rejected when multiple entries are loaded; caller must specify `config_entry_id`. + +## Quality Scale + +Silver. See `quality_scale.yaml` for full status. Gold blockers: `diagnostics` and `reconfiguration-flow`. + +## Roadmap + +- Incoming messages via Gateway callback webhooks +- Image/file support +- Remaining credits sensor +- Diagnostics platform +- Reconfiguration flow diff --git a/homeassistant/components/threema/__init__.py b/homeassistant/components/threema/__init__.py new file mode 100644 index 00000000000000..84de66aacc012f --- /dev/null +++ b/homeassistant/components/threema/__init__.py @@ -0,0 +1,158 @@ +"""The Threema Gateway integration.""" + +from __future__ import annotations + +import logging + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, + ServiceValidationError, +) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .client import ( + ThreemaAPIClient, + ThreemaAuthError, + ThreemaConnectionError, + ThreemaSendError, +) +from .const import CONF_API_SECRET, CONF_GATEWAY_ID, CONF_PRIVATE_KEY, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +PLATFORMS: list[Platform] = [Platform.IMAGE] + +type ThreemaConfigEntry = ConfigEntry[ThreemaAPIClient] + +CONF_CONFIG_ENTRY_ID = "config_entry_id" +CONF_RECIPIENT = "recipient" +CONF_MESSAGE = "message" + +RECIPIENT_SCHEMA = vol.All( + cv.string, + cv.matches_regex(r"^[0-9A-Za-z]{8}$"), + lambda value: value.upper(), +) + +SERVICE_SEND_MESSAGE = "send_message" +SERVICE_SEND_MESSAGE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Required(CONF_RECIPIENT): RECIPIENT_SCHEMA, + vol.Required(CONF_MESSAGE): cv.string, + } +) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Threema Gateway component.""" + + async def async_send_message(call: ServiceCall) -> None: + """Handle the send_message service call.""" + recipient = call.data[CONF_RECIPIENT] + message = call.data[CONF_MESSAGE] + + # Get the config entry - auto-select if not specified + entry_id = call.data.get(CONF_CONFIG_ENTRY_ID) + + if entry_id: + entry = hass.config_entries.async_get_entry(entry_id) + if not entry or entry.domain != DOMAIN: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entry_not_found", + ) + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entry_not_loaded", + ) + else: + # Auto-select: find any loaded Threema config entry + entries = [ + e + for e in hass.config_entries.async_entries(DOMAIN) + if e.state is ConfigEntryState.LOADED + ] + if not entries: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_entries_found", + ) + if len(entries) > 1: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="multiple_entries_found", + ) + entry = entries[0] + + # Send the message + client: ThreemaAPIClient = entry.runtime_data + try: + await client.send_text_message(recipient, message) + except ThreemaAuthError as err: + _LOGGER.warning( + "Authentication failed sending message; check your Gateway credentials" + ) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_error", + translation_placeholders={"error": str(err)}, + ) from err + except (ThreemaSendError, ThreemaConnectionError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_error", + translation_placeholders={"error": str(err)}, + ) from err + + hass.services.async_register( + DOMAIN, + SERVICE_SEND_MESSAGE, + async_send_message, + schema=SERVICE_SEND_MESSAGE_SCHEMA, + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ThreemaConfigEntry) -> bool: + """Set up Threema Gateway from a config entry.""" + client = ThreemaAPIClient( + hass, + gateway_id=entry.data[CONF_GATEWAY_ID], + api_secret=entry.data[CONF_API_SECRET], + private_key=entry.data.get(CONF_PRIVATE_KEY), + ) + + try: + await client.validate_credentials() + except ThreemaAuthError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + except ThreemaConnectionError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + + entry.runtime_data = client + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ThreemaConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/threema/client.py b/homeassistant/components/threema/client.py new file mode 100644 index 00000000000000..bf11640f8a1ccb --- /dev/null +++ b/homeassistant/components/threema/client.py @@ -0,0 +1,138 @@ +"""Threema Gateway API client.""" + +from __future__ import annotations + +import logging + +from threema.gateway import Connection, GatewayError, key +from threema.gateway.e2e import TextMessage +from threema.gateway.exception import GatewayServerError +from threema.gateway.simple import TextMessage as SimpleTextMessage + +from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + +# HTTP 401 from Threema Gateway means invalid credentials +_HTTP_UNAUTHORIZED = 401 + + +class ThreemaConnectionError(Exception): + """Error to indicate a connection issue with the Threema Gateway.""" + + +class ThreemaAuthError(Exception): + """Error to indicate invalid credentials for the Threema Gateway.""" + + +class ThreemaSendError(Exception): + """Error to indicate a message send failure.""" + + +class ThreemaAPIClient: + """Threema Gateway API client.""" + + def __init__( + self, + hass: HomeAssistant, + gateway_id: str, + api_secret: str, + private_key: str | None = None, + ) -> None: + """Initialize the Threema API client.""" + self.hass = hass + self.gateway_id = gateway_id + self.api_secret = api_secret + self.private_key = private_key + + def _get_connection(self) -> Connection: + """Get a Threema Gateway connection. + + Note: Connection manages its own aiohttp session lifecycle. + Do not pass HA's shared session as Connection will close it. + """ + return Connection( + identity=self.gateway_id, + secret=self.api_secret, + key=self.private_key, + ) + + async def validate_credentials(self) -> None: + """Validate the Gateway credentials by checking credits. + + Raises ThreemaAuthError for invalid credentials. + Raises ThreemaConnectionError for other failures. + """ + try: + async with self._get_connection() as conn: + remaining_credits = await conn.get_credits() + _LOGGER.debug( + "Gateway credentials validated, credits: %s", + remaining_credits, + ) + except GatewayServerError as err: + if err.status == _HTTP_UNAUTHORIZED: + raise ThreemaAuthError("Invalid Threema Gateway credentials") from err + raise ThreemaConnectionError( + f"Gateway server error validating credentials: {err}" + ) from err + except GatewayError as err: + raise ThreemaConnectionError( + f"Gateway error validating credentials: {err}" + ) from err + except Exception as err: + raise ThreemaConnectionError( + f"Failed to validate credentials: {err}" + ) from err + + async def send_text_message(self, recipient_id: str, text: str) -> str: + """Send a text message to a Threema ID. + + Returns the message ID on success. + Raises ThreemaSendError on failure. + """ + try: + async with self._get_connection() as conn: + if self.private_key: + _LOGGER.debug("Sending E2E encrypted message to %s", recipient_id) + message = TextMessage( + connection=conn, + to_id=recipient_id, + text=text, + ) + else: + _LOGGER.debug("Sending simple message to %s", recipient_id) + message = SimpleTextMessage( + connection=conn, + to_id=recipient_id, + text=text, + ) + + message_id: str = await message.send() + _LOGGER.debug("Message sent to %s (ID: %s)", recipient_id, message_id) + return message_id + except GatewayServerError as err: + if err.status == _HTTP_UNAUTHORIZED: + raise ThreemaAuthError("Invalid Threema Gateway credentials") from err + raise ThreemaSendError( + f"Gateway server error sending message to {recipient_id}: {err}" + ) from err + except GatewayError as err: + raise ThreemaSendError( + f"Gateway error sending message to {recipient_id}: {err}" + ) from err + except Exception as err: + raise ThreemaSendError( + f"Failed to send message to {recipient_id}: {err}" + ) from err + + +def generate_key_pair() -> tuple[str, str]: + """Generate a new key pair for E2E encryption using official SDK. + + Returns tuple of (private_key, public_key) as encoded strings. + """ + private_key_obj, public_key_obj = key.Key.generate_pair() + private_key_str = key.Key.encode(private_key_obj) + public_key_str = key.Key.encode(public_key_obj) + return private_key_str, public_key_str diff --git a/homeassistant/components/threema/config_flow.py b/homeassistant/components/threema/config_flow.py new file mode 100644 index 00000000000000..a04f7a5fcf3be0 --- /dev/null +++ b/homeassistant/components/threema/config_flow.py @@ -0,0 +1,216 @@ +"""Config flow for Threema Gateway integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult + +from .client import ( + ThreemaAPIClient, + ThreemaAuthError, + ThreemaConnectionError, + generate_key_pair, +) +from .const import ( + CONF_API_SECRET, + CONF_GATEWAY_ID, + CONF_PRIVATE_KEY, + CONF_PUBLIC_KEY, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +class ThreemaConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Threema Gateway.""" + + VERSION = 1 + MINOR_VERSION = 1 + + _gateway_id: str | None = None + _api_secret: str | None = None + _private_key: str | None = None + _public_key: str | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step - choose setup type.""" + if user_input is not None: + if user_input.get("setup_type") == "new": + return await self.async_step_setup_new() + return await self.async_step_credentials() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required("setup_type", default="existing"): vol.In( + ["existing", "new"] + ), + } + ), + ) + + async def async_step_setup_new( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Generate keys for a new Gateway ID.""" + if user_input is not None: + if user_input.get(CONF_PRIVATE_KEY): + self._private_key = user_input[CONF_PRIVATE_KEY] + if user_input.get(CONF_PUBLIC_KEY): + self._public_key = user_input[CONF_PUBLIC_KEY] + return await self.async_step_credentials() + + try: + private_key, public_key = await self.hass.async_add_executor_job( + generate_key_pair + ) + self._private_key = private_key + self._public_key = public_key + except Exception: + _LOGGER.exception("Failed to generate key pair") + return self.async_abort(reason="key_generation_failed") + + return self.async_show_form( + step_id="setup_new", + data_schema=vol.Schema( + { + vol.Optional(CONF_PUBLIC_KEY, default=public_key): str, + vol.Optional(CONF_PRIVATE_KEY, default=private_key): str, + } + ), + ) + + async def async_step_credentials( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Collect Gateway credentials.""" + errors: dict[str, str] = {} + + if user_input is not None: + gateway_id = user_input[CONF_GATEWAY_ID].strip().upper() + + if not gateway_id.startswith("*") or len(gateway_id) != 8: + errors["base"] = "invalid_gateway_id" + else: + await self.async_set_unique_id(gateway_id) + self._abort_if_unique_id_configured() + + self._gateway_id = gateway_id + self._api_secret = user_input[CONF_API_SECRET] + + if user_input.get(CONF_PRIVATE_KEY): + self._private_key = user_input[CONF_PRIVATE_KEY] + if user_input.get(CONF_PUBLIC_KEY): + self._public_key = user_input[CONF_PUBLIC_KEY] + + client = ThreemaAPIClient( + self.hass, + gateway_id=gateway_id, + api_secret=user_input[CONF_API_SECRET], + private_key=self._private_key, + ) + + try: + await client.validate_credentials() + except ThreemaAuthError: + errors["base"] = "invalid_auth" + except ThreemaConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error validating credentials") + errors["base"] = "unknown" + else: + data: dict[str, str] = { + CONF_GATEWAY_ID: self._gateway_id, + CONF_API_SECRET: self._api_secret, + } + if self._private_key: + data[CONF_PRIVATE_KEY] = self._private_key + if self._public_key: + data[CONF_PUBLIC_KEY] = self._public_key + + return self.async_create_entry( + title=f"Threema {self._gateway_id}", + data=data, + ) + + schema = vol.Schema( + { + vol.Required(CONF_GATEWAY_ID): str, + vol.Required(CONF_API_SECRET): str, + vol.Optional(CONF_PRIVATE_KEY, default=self._private_key or ""): str, + vol.Optional(CONF_PUBLIC_KEY, default=self._public_key or ""): str, + } + ) + + return self.async_show_form( + step_id="credentials", + data_schema=schema, + errors=errors, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth if credentials become invalid.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauth confirmation.""" + errors: dict[str, str] = {} + + if user_input is not None: + reauth_entry = self._get_reauth_entry() + private_key_input = user_input.get(CONF_PRIVATE_KEY) or None + + client = ThreemaAPIClient( + self.hass, + gateway_id=reauth_entry.data[CONF_GATEWAY_ID], + api_secret=user_input[CONF_API_SECRET], + private_key=private_key_input + or reauth_entry.data.get(CONF_PRIVATE_KEY), + ) + + try: + await client.validate_credentials() + except ThreemaAuthError: + errors["base"] = "invalid_auth" + except ThreemaConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error validating new credentials") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(reauth_entry.data[CONF_GATEWAY_ID]) + self._abort_if_unique_id_mismatch() + data_updates: dict[str, str] = { + CONF_API_SECRET: user_input[CONF_API_SECRET], + } + if private_key_input: + data_updates[CONF_PRIVATE_KEY] = private_key_input + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=data_updates, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_API_SECRET): str, + vol.Optional(CONF_PRIVATE_KEY): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/threema/const.py b/homeassistant/components/threema/const.py new file mode 100644 index 00000000000000..9cb071c61ac0b0 --- /dev/null +++ b/homeassistant/components/threema/const.py @@ -0,0 +1,8 @@ +"""Constants for the Threema Gateway integration.""" + +DOMAIN = "threema" + +CONF_GATEWAY_ID = "gateway_id" +CONF_API_SECRET = "api_secret" +CONF_PRIVATE_KEY = "private_key" +CONF_PUBLIC_KEY = "public_key" diff --git a/homeassistant/components/threema/icons.json b/homeassistant/components/threema/icons.json new file mode 100644 index 00000000000000..73b4c06ca735ae --- /dev/null +++ b/homeassistant/components/threema/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "send_message": { + "service": "mdi:message-text-lock" + } + } +} diff --git a/homeassistant/components/threema/image.py b/homeassistant/components/threema/image.py new file mode 100644 index 00000000000000..96297550b0c13e --- /dev/null +++ b/homeassistant/components/threema/image.py @@ -0,0 +1,90 @@ +"""Image platform for Threema Gateway integration.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from io import BytesIO + +import qrcode + +from homeassistant.components.image import ImageEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ThreemaConfigEntry +from .const import CONF_GATEWAY_ID, CONF_PUBLIC_KEY, DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ThreemaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Threema image entities from a config entry.""" + if entry.data.get(CONF_PUBLIC_KEY): + async_add_entities([ThreemaQRCodeImage(hass, entry)]) + + +class ThreemaQRCodeImage(ImageEntity): + """Image entity that displays the gateway's public key as a QR code.""" + + _attr_has_entity_name = True + _attr_translation_key = "gateway_qr_code" + _attr_content_type = "image/png" + + def __init__(self, hass: HomeAssistant, entry: ThreemaConfigEntry) -> None: + """Initialize the QR code image entity.""" + super().__init__(hass) + self._entry = entry + self._qr_image_bytes: bytes | None = None + + gateway_id = entry.data[CONF_GATEWAY_ID] + self._attr_unique_id = f"{gateway_id}_qr_code" + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, gateway_id)}, + name=f"Threema {gateway_id}", + manufacturer="Threema", + model="Gateway", + configuration_url="https://gateway.threema.ch", + ) + + async def async_added_to_hass(self) -> None: + """Generate QR code when entity is added to hass.""" + await super().async_added_to_hass() + await self.hass.async_add_executor_job(self._generate_qr_code) + if self._qr_image_bytes is not None: + self._attr_image_last_updated = datetime.now(UTC) + + def _generate_qr_code(self) -> None: + """Generate QR code from the public key.""" + public_key = self._entry.data.get(CONF_PUBLIC_KEY) + if not public_key: + return + + gateway_id = self._entry.data[CONF_GATEWAY_ID] + public_key_hex = public_key.replace("public:", "").strip().lower() + qr_data = f"3mid:{gateway_id},{public_key_hex}" + + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_L, + box_size=10, + border=4, + ) + qr.add_data(qr_data) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + buffer = BytesIO() + img.save(buffer, format="PNG") + self._qr_image_bytes = buffer.getvalue() + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self._qr_image_bytes is not None + + async def async_image(self) -> bytes | None: + """Return the image bytes.""" + return self._qr_image_bytes diff --git a/homeassistant/components/threema/manifest.json b/homeassistant/components/threema/manifest.json new file mode 100644 index 00000000000000..b4f848272e2abe --- /dev/null +++ b/homeassistant/components/threema/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "threema", + "name": "Threema", + "codeowners": ["@LukasQ"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/threema", + "integration_type": "service", + "iot_class": "cloud_push", + "loggers": ["qrcode", "threema"], + "quality_scale": "silver", + "requirements": ["qrcode==8.2", "threema.gateway==8.0.0"] +} diff --git a/homeassistant/components/threema/quality_scale.yaml b/homeassistant/components/threema/quality_scale.yaml new file mode 100644 index 00000000000000..b241020eebf144 --- /dev/null +++ b/homeassistant/components/threema/quality_scale.yaml @@ -0,0 +1,89 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register entity actions. + appropriate-polling: + status: exempt + comment: Integration only sends messages, no polling required. + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not provide entity actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not use event subscriptions. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not register entity actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: + status: exempt + comment: Image entity availability is based on local QR code generation, not external service state. + parallel-updates: + status: exempt + comment: Image entity is static (generated once), no parallel update concerns. + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery: + status: exempt + comment: Cloud service, not discoverable on the network. + discovery-update-info: + status: exempt + comment: Cloud service, not discoverable on the network. + docs-data-update: + status: exempt + comment: Integration does not poll or update data. + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: Service integration, no physical devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: Single static gateway device per config entry. + entity-category: + status: exempt + comment: QR code image entity is a primary security verification feature, no diagnostic or config category applies. + entity-device-class: + status: exempt + comment: No applicable device class for QR code image entity. + entity-disabled-by-default: + status: exempt + comment: QR code entity is used for gateway identity verification and should be visible by default. + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No scenarios requiring repair issues. + stale-devices: + status: exempt + comment: Single static device per config entry. diff --git a/homeassistant/components/threema/services.yaml b/homeassistant/components/threema/services.yaml new file mode 100644 index 00000000000000..3a414944956a5f --- /dev/null +++ b/homeassistant/components/threema/services.yaml @@ -0,0 +1,16 @@ +send_message: + fields: + config_entry_id: + required: false + selector: + config_entry: + integration: threema + recipient: + required: true + selector: + text: + message: + required: true + selector: + text: + multiline: true diff --git a/homeassistant/components/threema/strings.json b/homeassistant/components/threema/strings.json new file mode 100644 index 00000000000000..6a93aa0c300e6e --- /dev/null +++ b/homeassistant/components/threema/strings.json @@ -0,0 +1,117 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "key_generation_failed": "Failed to generate encryption keys.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_gateway_id": "Gateway ID must be 8 characters long and start with *.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "credentials": { + "data": { + "api_secret": "API secret", + "gateway_id": "Gateway ID", + "private_key": "Private key (mandatory for E2E encryption)", + "public_key": "Public key (mandatory for E2E encryption)" + }, + "data_description": { + "api_secret": "Secret you received from Threema", + "gateway_id": "ID of your Gateway, including '*', e.g.: *ABCD123", + "private_key": "Private key, including 'private:' prefix", + "public_key": "Public key, including 'public:' prefix" + }, + "description": "Enter your Threema Gateway ID and secret obtained from gateway.threema.ch.", + "title": "Gateway credentials" + }, + "reauth_confirm": { + "data": { + "api_secret": "API secret", + "private_key": "Private key (mandatory for E2E encryption)" + }, + "data_description": { + "api_secret": "Enter the new API secret from gateway.threema.ch.", + "private_key": "Private key, including 'private:' prefix" + }, + "description": "Your Threema Gateway credentials need to be updated.", + "title": "[%key:common::config_flow::title::reauth%]" + }, + "setup_new": { + "data": { + "private_key": "Private key", + "public_key": "Public key" + }, + "data_description": { + "private_key": "Your generated private key. Store it securely.", + "public_key": "Your generated public key. Use when registering at gateway.threema.ch." + }, + "description": "Save your keys now! When registering at gateway.threema.ch, paste only the hex part of your public key (without the 'public:' prefix). Save your private key securely.", + "title": "Keys generated" + }, + "user": { + "data": { + "setup_type": "Setup type" + }, + "data_description": { + "setup_type": "Choose 'existing' to enter your Gateway credentials, or 'new' to generate encryption keys first." + }, + "description": "Choose whether to create a new Gateway ID or configure an existing one.", + "title": "Threema setup" + } + } + }, + "entity": { + "image": { + "gateway_qr_code": { + "name": "Gateway QR code" + } + } + }, + "exceptions": { + "cannot_connect": { + "message": "[%key:common::config_flow::error::cannot_connect%]" + }, + "entry_not_found": { + "message": "Config entry not found." + }, + "entry_not_loaded": { + "message": "Config entry is not loaded." + }, + "invalid_auth": { + "message": "[%key:common::config_flow::error::invalid_auth%]" + }, + "multiple_entries_found": { + "message": "Multiple Threema integrations configured. Please specify a config_entry_id." + }, + "no_entries_found": { + "message": "No Threema integration configured or loaded." + }, + "send_error": { + "message": "Error sending message: {error}" + } + }, + "services": { + "send_message": { + "description": "Send a text message via Threema Gateway.", + "fields": { + "config_entry_id": { + "description": "The Threema Gateway config entry to use.", + "name": "Config entry" + }, + "message": { + "description": "The text message to send.", + "name": "Message" + }, + "recipient": { + "description": "Threema ID of the recipient (8 characters).", + "name": "Recipient" + } + }, + "name": "Send message" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 37b23a29df34fb..44eba40c8b9649 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -719,6 +719,7 @@ "thermopro", "thethingsnetwork", "thread", + "threema", "tibber", "tile", "tilt_ble", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7c2d6a13770f90..07b56d0316dd41 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7031,6 +7031,12 @@ "iot_class": "local_polling", "single_config_entry": true }, + "threema": { + "name": "Threema", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_push" + }, "tibber": { "name": "Tibber", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 0e48a0bb8c4af6..a01b31b4e84dba 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5248,6 +5248,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.threema.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.threshold.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index a15ac2cd57fcde..7c90aaafab7a4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2783,6 +2783,9 @@ qingping-ble==1.1.0 # homeassistant.components.qnap qnapstats==0.4.0 +# homeassistant.components.threema +qrcode==8.2 + # homeassistant.components.quantum_gateway quantum-gateway==0.0.8 @@ -3096,6 +3099,9 @@ thingspeak==1.0.0 # homeassistant.components.lg_thinq thinqconnect==1.0.9 +# homeassistant.components.threema +threema.gateway==8.0.0 + # homeassistant.components.tikteck tikteck==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a2df235650e00c..7164ae21fd92d5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2361,6 +2361,9 @@ qingping-ble==1.1.0 # homeassistant.components.qnap qnapstats==0.4.0 +# homeassistant.components.threema +qrcode==8.2 + # homeassistant.components.quantum_gateway quantum-gateway==0.0.8 @@ -2608,6 +2611,9 @@ thermopro-ble==1.1.3 # homeassistant.components.lg_thinq thinqconnect==1.0.9 +# homeassistant.components.threema +threema.gateway==8.0.0 + # homeassistant.components.tilt_ble tilt-ble==1.0.1 diff --git a/tests/components/threema/__init__.py b/tests/components/threema/__init__.py new file mode 100644 index 00000000000000..a7bc4a6805f0ad --- /dev/null +++ b/tests/components/threema/__init__.py @@ -0,0 +1 @@ +"""Tests for the Threema Gateway integration.""" diff --git a/tests/components/threema/conftest.py b/tests/components/threema/conftest.py new file mode 100644 index 00000000000000..145483df8af8cf --- /dev/null +++ b/tests/components/threema/conftest.py @@ -0,0 +1,108 @@ +"""Fixtures for Threema Gateway integration tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.threema.const import ( + CONF_API_SECRET, + CONF_GATEWAY_ID, + CONF_PRIVATE_KEY, + CONF_PUBLIC_KEY, + DOMAIN, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_GATEWAY_ID = "*TESTGWY" +MOCK_API_SECRET = "test_secret_key_12345" +MOCK_PRIVATE_KEY = ( + "private:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +) +MOCK_PUBLIC_KEY = ( + "public:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" +) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mocked config entry.""" + return MockConfigEntry( + title=f"Threema {MOCK_GATEWAY_ID}", + domain=DOMAIN, + data={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + unique_id=MOCK_GATEWAY_ID, + ) + + +@pytest.fixture +def mock_config_entry_with_keys() -> MockConfigEntry: + """Return a mocked config entry with encryption keys.""" + return MockConfigEntry( + title=f"Threema {MOCK_GATEWAY_ID}", + domain=DOMAIN, + data={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: MOCK_PRIVATE_KEY, + CONF_PUBLIC_KEY: MOCK_PUBLIC_KEY, + }, + unique_id=MOCK_GATEWAY_ID, + ) + + +@pytest.fixture +def mock_connection() -> Generator[MagicMock]: + """Mock the Threema Gateway Connection.""" + with patch( + "homeassistant.components.threema.client.Connection", autospec=True + ) as connection_class: + connection = MagicMock() + connection.__aenter__ = AsyncMock(return_value=connection) + connection.__aexit__ = AsyncMock(return_value=None) + connection.get_credits = AsyncMock(return_value=100) + connection_class.return_value = connection + yield connection + + +@pytest.fixture +def mock_send() -> Generator[MagicMock]: + """Mock TextMessage and SimpleTextMessage send methods.""" + with ( + patch( + "homeassistant.components.threema.client.TextMessage", autospec=True + ) as e2e_mock, + patch( + "homeassistant.components.threema.client.SimpleTextMessage", autospec=True + ) as simple_mock, + ): + e2e_instance = MagicMock() + e2e_instance.send = AsyncMock(return_value="mock_message_id") + e2e_mock.return_value = e2e_instance + + simple_instance = MagicMock() + simple_instance.send = AsyncMock(return_value="mock_message_id") + simple_mock.return_value = simple_instance + + yield MagicMock(e2e=e2e_mock, simple=simple_mock) + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: MagicMock, +) -> MockConfigEntry: + """Set up the Threema integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry diff --git a/tests/components/threema/test_config_flow.py b/tests/components/threema/test_config_flow.py new file mode 100644 index 00000000000000..32009fbebd535c --- /dev/null +++ b/tests/components/threema/test_config_flow.py @@ -0,0 +1,380 @@ +"""Test the Threema Gateway config flow.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.threema.client import ThreemaAuthError +from homeassistant.components.threema.const import ( + CONF_API_SECRET, + CONF_GATEWAY_ID, + CONF_PRIVATE_KEY, + CONF_PUBLIC_KEY, + DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import MOCK_API_SECRET, MOCK_GATEWAY_ID, MOCK_PRIVATE_KEY + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def mock_setup_entry(): + """Patch async_setup_entry to avoid full setup during flow tests.""" + with patch("homeassistant.components.threema.async_setup_entry", return_value=True): + yield + + +async def test_user_flow_existing_gateway( + hass: HomeAssistant, mock_connection: MagicMock +) -> None: + """Test user flow with existing gateway credentials.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Choose existing gateway + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"setup_type": "existing"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "credentials" + + # Enter credentials + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Threema {MOCK_GATEWAY_ID}" + assert result["data"] == { + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + } + + +async def test_user_flow_existing_with_keys( + hass: HomeAssistant, mock_connection: MagicMock +) -> None: + """Test user flow with existing gateway including optional keys.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"setup_type": "existing"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: "private:abcdef1234567890", + CONF_PUBLIC_KEY: "public:1234567890abcdef", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_PRIVATE_KEY] == "private:abcdef1234567890" + assert result["data"][CONF_PUBLIC_KEY] == "public:1234567890abcdef" + + +async def test_user_flow_new_gateway( + hass: HomeAssistant, mock_connection: MagicMock +) -> None: + """Test user flow with new gateway (key generation).""" + mock_private = "private:generated_private_key_hex" + mock_public = "public:generated_public_key_hex" + + with patch( + "homeassistant.components.threema.config_flow.generate_key_pair", + return_value=(mock_private, mock_public), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Choose new gateway + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"setup_type": "new"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "setup_new" + + # Confirm keys and proceed + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "public_key": mock_public, + "private_key": mock_private, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "credentials" + + # Enter gateway credentials + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_PRIVATE_KEY] == mock_private + assert result["data"][CONF_PUBLIC_KEY] == mock_public + + +async def test_user_flow_key_generation_failure(hass: HomeAssistant) -> None: + """Test user flow aborts when key generation fails.""" + with patch( + "homeassistant.components.threema.config_flow.generate_key_pair", + side_effect=RuntimeError("Key generation failed"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"setup_type": "new"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "key_generation_failed" + + +async def test_credentials_invalid_gateway_id( + hass: HomeAssistant, mock_connection: MagicMock +) -> None: + """Test credentials step with invalid Gateway ID.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"setup_type": "existing"}, + ) + + # Gateway ID not starting with * + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: "TESTGWY1", + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_gateway_id"} + + # Gateway ID wrong length + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: "*TEST", + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_gateway_id"} + + +async def test_credentials_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, +) -> None: + """Test credentials step when gateway is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"setup_type": "existing"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_credentials_cannot_connect(hass: HomeAssistant) -> None: + """Test credentials step when connection fails.""" + with patch( + "homeassistant.components.threema.client.Connection", autospec=True + ) as connection_class: + connection = MagicMock() + connection.__aenter__ = AsyncMock(return_value=connection) + connection.__aexit__ = AsyncMock(return_value=None) + connection.get_credits = AsyncMock(side_effect=Exception("Connection refused")) + connection_class.return_value = connection + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"setup_type": "existing"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_credentials_invalid_auth(hass: HomeAssistant) -> None: + """Test credentials step with invalid authentication.""" + with patch( + "homeassistant.components.threema.config_flow.ThreemaAPIClient.validate_credentials", + side_effect=ThreemaAuthError("Invalid credentials"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"setup_type": "existing"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_reauth_flow_invalid_auth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with invalid credentials.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.threema.config_flow.ThreemaAPIClient.validate_credentials", + side_effect=ThreemaAuthError("Invalid credentials"), + ): + result = await mock_config_entry.start_reauth_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_SECRET: "wrong_secret", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_reauth_flow_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, +) -> None: + """Test reauth flow succeeds with valid credentials.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_SECRET: "new_secret", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_SECRET] == "new_secret" + + +async def test_reauth_flow_preserves_private_key( + hass: HomeAssistant, + mock_config_entry_with_keys: MockConfigEntry, + mock_connection: MagicMock, +) -> None: + """Test reauth flow preserves existing private key when not provided.""" + mock_config_entry_with_keys.add_to_hass(hass) + + result = await mock_config_entry_with_keys.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_SECRET: "new_secret", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry_with_keys.data[CONF_API_SECRET] == "new_secret" + assert mock_config_entry_with_keys.data[CONF_PRIVATE_KEY] == MOCK_PRIVATE_KEY + + +async def test_reauth_flow_cannot_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with connection failure.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.threema.client.Connection", autospec=True + ) as connection_class: + connection = MagicMock() + connection.__aenter__ = AsyncMock(return_value=connection) + connection.__aexit__ = AsyncMock(return_value=None) + connection.get_credits = AsyncMock(side_effect=Exception("Connection refused")) + connection_class.return_value = connection + + result = await mock_config_entry.start_reauth_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_SECRET: "new_secret", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/threema/test_image.py b/tests/components/threema/test_image.py new file mode 100644 index 00000000000000..a07a24629bacdb --- /dev/null +++ b/tests/components/threema/test_image.py @@ -0,0 +1,94 @@ +"""Test the Threema Gateway image platform.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_qr_code_entity_created( + hass: HomeAssistant, + mock_config_entry_with_keys: MockConfigEntry, + mock_connection: MagicMock, + mock_send: MagicMock, +) -> None: + """Test QR code entity is created when public key is present.""" + mock_config_entry_with_keys.add_to_hass(hass) + + with patch("homeassistant.components.threema.image.qrcode.QRCode") as mock_qr_class: + mock_qr = MagicMock() + mock_qr_class.return_value = mock_qr + mock_img = MagicMock() + mock_qr.make_image.return_value = mock_img + mock_img.save = MagicMock( + side_effect=lambda buf, **kwargs: buf.write(b"fake_png_data") + ) + + await hass.config_entries.async_setup(mock_config_entry_with_keys.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry_with_keys.entry_id + ) + image_entities = [e for e in entities if e.domain == "image"] + assert len(image_entities) == 1 + assert image_entities[0].unique_id == "*TESTGWY_qr_code" + + +async def test_qr_code_entity_not_created_without_key( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: MagicMock, +) -> None: + """Test QR code entity is NOT created when no public key.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + image_entities = [e for e in entities if e.domain == "image"] + assert len(image_entities) == 0 + + +async def test_qr_code_image_available( + hass: HomeAssistant, + mock_config_entry_with_keys: MockConfigEntry, + mock_connection: MagicMock, + mock_send: MagicMock, +) -> None: + """Test QR code entity is available when image is generated.""" + mock_config_entry_with_keys.add_to_hass(hass) + + with patch("homeassistant.components.threema.image.qrcode.QRCode") as mock_qr_class: + mock_qr = MagicMock() + mock_qr_class.return_value = mock_qr + mock_img = MagicMock() + mock_qr.make_image.return_value = mock_img + mock_img.save = MagicMock( + side_effect=lambda buf, **kwargs: buf.write(b"fake_png_data") + ) + + await hass.config_entries.async_setup(mock_config_entry_with_keys.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry_with_keys.entry_id + ) + image_entities = [e for e in entities if e.domain == "image"] + assert len(image_entities) == 1 + + state = hass.states.get(image_entities[0].entity_id) + assert state is not None + assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/threema/test_init.py b/tests/components/threema/test_init.py new file mode 100644 index 00000000000000..79e37061449fd7 --- /dev/null +++ b/tests/components/threema/test_init.py @@ -0,0 +1,305 @@ +"""Test the Threema Gateway integration setup.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from threema.gateway.exception import GatewayServerError + +from homeassistant.components.threema.client import ThreemaAuthError +from homeassistant.components.threema.const import ( + CONF_API_SECRET, + CONF_GATEWAY_ID, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_setup_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: MagicMock, +) -> None: + """Test successful setup of a config entry.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_setup_entry_connection_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup retries on connection error (ConfigEntryNotReady).""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.threema.client.Connection", autospec=True + ) as connection_class: + connection = MagicMock() + connection.__aenter__ = AsyncMock(return_value=connection) + connection.__aexit__ = AsyncMock(return_value=None) + connection.get_credits = AsyncMock(side_effect=Exception("Connection refused")) + connection_class.return_value = connection + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_auth_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup fails on auth error (ConfigEntryAuthFailed).""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.threema.ThreemaAPIClient.validate_credentials", + side_effect=ThreemaAuthError("Invalid credentials"), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: MagicMock, +) -> None: + """Test unloading a config entry.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_send_message_service( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_send: MagicMock, +) -> None: + """Test the send_message service call.""" + await hass.services.async_call( + DOMAIN, + "send_message", + { + "config_entry_id": init_integration.entry_id, + "recipient": "ABCD1234", + "message": "Hello from tests!", + }, + blocking=True, + ) + + # Verify SimpleTextMessage was used (no private key) + mock_send.simple.assert_called_once() + + +async def test_send_message_service_auto_select( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_send: MagicMock, +) -> None: + """Test send_message service auto-selects entry when not specified.""" + await hass.services.async_call( + DOMAIN, + "send_message", + { + "recipient": "ABCD1234", + "message": "Hello from tests!", + }, + blocking=True, + ) + + mock_send.simple.assert_called_once() + + +async def test_send_message_service_entry_not_found( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test send_message service with invalid entry ID.""" + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + "send_message", + { + "config_entry_id": "nonexistent_entry_id", + "recipient": "ABCD1234", + "message": "Hello!", + }, + blocking=True, + ) + + +async def test_send_message_service_no_loaded_entries( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: MagicMock, +) -> None: + """Test send_message service raises when no entries are loaded.""" + mock_config_entry.add_to_hass(hass) + + # Setup then unload so service is registered but no entry is loaded + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + "send_message", + { + "recipient": "ABCD1234", + "message": "Hello!", + }, + blocking=True, + ) + + +async def test_send_message_service_send_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, +) -> None: + """Test send_message service handles send failure.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.threema.client.SimpleTextMessage", autospec=True + ) as simple_mock: + simple_instance = MagicMock() + simple_instance.send = AsyncMock(side_effect=Exception("Network error")) + simple_mock.return_value = simple_instance + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + "send_message", + { + "recipient": "ABCD1234", + "message": "Hello!", + }, + blocking=True, + ) + + +async def test_send_message_service_auth_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, +) -> None: + """Test send_message service handles auth failure during send.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.threema.client.SimpleTextMessage", autospec=True + ) as simple_mock: + simple_instance = MagicMock() + simple_instance.send = AsyncMock(side_effect=GatewayServerError(status=401)) + simple_mock.return_value = simple_instance + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + "send_message", + { + "recipient": "ABCD1234", + "message": "Hello!", + }, + blocking=True, + ) + + +async def test_send_message_service_e2e( + hass: HomeAssistant, + mock_config_entry_with_keys: MockConfigEntry, + mock_connection: MagicMock, + mock_send: MagicMock, +) -> None: + """Test send_message service uses E2E TextMessage when private key is set.""" + mock_config_entry_with_keys.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_keys.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + "send_message", + { + "config_entry_id": mock_config_entry_with_keys.entry_id, + "recipient": "ABCD1234", + "message": "Hello E2E!", + }, + blocking=True, + ) + + mock_send.e2e.assert_called_once() + + +async def test_send_message_service_multiple_entries( + hass: HomeAssistant, + mock_connection: MagicMock, + mock_send: MagicMock, +) -> None: + """Test send_message service raises when multiple entries and no ID specified.""" + entry1 = MockConfigEntry( + title="Threema *FIRST01", + domain=DOMAIN, + data={ + CONF_GATEWAY_ID: "*FIRST01", + CONF_API_SECRET: "first_secret", + }, + unique_id="*FIRST01", + ) + entry2 = MockConfigEntry( + title="Threema *SECOND1", + domain=DOMAIN, + data={ + CONF_GATEWAY_ID: "*SECOND1", + CONF_API_SECRET: "second_secret", + }, + unique_id="*SECOND1", + ) + entry1.add_to_hass(hass) + entry2.add_to_hass(hass) + + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + "send_message", + { + "recipient": "ABCD1234", + "message": "Hello!", + }, + blocking=True, + ) From 38d0986f7eb83cd50e2cc31059195847eefadf87 Mon Sep 17 00:00:00 2001 From: Lukas Date: Thu, 19 Mar 2026 11:22:55 +0100 Subject: [PATCH 02/40] Update homeassistant/components/threema/strings.json good catch. Co-authored-by: Norbert Rittel --- homeassistant/components/threema/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/threema/strings.json b/homeassistant/components/threema/strings.json index 6a93aa0c300e6e..7d5b8d5573183d 100644 --- a/homeassistant/components/threema/strings.json +++ b/homeassistant/components/threema/strings.json @@ -30,8 +30,8 @@ }, "reauth_confirm": { "data": { - "api_secret": "API secret", - "private_key": "Private key (mandatory for E2E encryption)" + "api_secret": "[%key:component::threema::config::step::credentials::data::api_secret%]", + "private_key": "[%key:component::threema::config::step::credentials::data::private_key%]" }, "data_description": { "api_secret": "Enter the new API secret from gateway.threema.ch.", From dd4eaebdef2bee86df94eeb5759b73399e4e1a97 Mon Sep 17 00:00:00 2001 From: Lukas Date: Thu, 19 Mar 2026 11:23:03 +0100 Subject: [PATCH 03/40] Update homeassistant/components/threema/strings.json Co-authored-by: Norbert Rittel --- homeassistant/components/threema/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/threema/strings.json b/homeassistant/components/threema/strings.json index 7d5b8d5573183d..17d497754aabd2 100644 --- a/homeassistant/components/threema/strings.json +++ b/homeassistant/components/threema/strings.json @@ -96,7 +96,7 @@ }, "services": { "send_message": { - "description": "Send a text message via Threema Gateway.", + "description": "Sends a text message via Threema Gateway.", "fields": { "config_entry_id": { "description": "The Threema Gateway config entry to use.", From b01d3c28ae751b27965b770d166edc16f7adba29 Mon Sep 17 00:00:00 2001 From: LukasQ Date: Thu, 19 Mar 2026 11:36:50 +0100 Subject: [PATCH 04/40] Fixed Copolot issue #2 and #3 --- homeassistant/components/threema/image.py | 9 ++++++++- tests/components/threema/test_init.py | 7 ++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/threema/image.py b/homeassistant/components/threema/image.py index 96297550b0c13e..5a12beccea4a45 100644 --- a/homeassistant/components/threema/image.py +++ b/homeassistant/components/threema/image.py @@ -4,6 +4,7 @@ from datetime import UTC, datetime from io import BytesIO +import logging import qrcode @@ -15,6 +16,8 @@ from . import ThreemaConfigEntry from .const import CONF_GATEWAY_ID, CONF_PUBLIC_KEY, DOMAIN +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -52,7 +55,11 @@ def __init__(self, hass: HomeAssistant, entry: ThreemaConfigEntry) -> None: async def async_added_to_hass(self) -> None: """Generate QR code when entity is added to hass.""" await super().async_added_to_hass() - await self.hass.async_add_executor_job(self._generate_qr_code) + try: + await self.hass.async_add_executor_job(self._generate_qr_code) + except Exception: + _LOGGER.exception("Error generating Threema gateway QR code") + return if self._qr_image_bytes is not None: self._attr_image_last_updated = datetime.now(UTC) diff --git a/tests/components/threema/test_init.py b/tests/components/threema/test_init.py index 79e37061449fd7..d82f1d9c18e020 100644 --- a/tests/components/threema/test_init.py +++ b/tests/components/threema/test_init.py @@ -293,7 +293,10 @@ async def test_send_message_service_multiple_entries( await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - with pytest.raises(ServiceValidationError): + assert entry1.state is ConfigEntryState.LOADED + assert entry2.state is ConfigEntryState.LOADED + + with pytest.raises(ServiceValidationError) as exc_info: await hass.services.async_call( DOMAIN, "send_message", @@ -303,3 +306,5 @@ async def test_send_message_service_multiple_entries( }, blocking=True, ) + + assert exc_info.value.translation_key == "multiple_entries_found" From 60092cd9df2696af39fa2b9eab84d517d534e405 Mon Sep 17 00:00:00 2001 From: LukasQ Date: Thu, 19 Mar 2026 12:25:15 +0100 Subject: [PATCH 05/40] Added new roadmap ideas --- homeassistant/components/threema/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/threema/README.md b/homeassistant/components/threema/README.md index 1bbd99583e4d74..7a8495d2ed6dd7 100644 --- a/homeassistant/components/threema/README.md +++ b/homeassistant/components/threema/README.md @@ -31,7 +31,7 @@ Silver. See `quality_scale.yaml` for full status. Gold blockers: `diagnostics` a ## Roadmap - Incoming messages via Gateway callback webhooks -- Image/file support +- Image/file support via notify entity `data` parameter (camera entity snapshots, URLs, local files → Threema SDK `ImageMessage`) - Remaining credits sensor - Diagnostics platform - Reconfiguration flow From 5481ad04933d5e6af8a31490e5a0c816b830b32b Mon Sep 17 00:00:00 2001 From: LukasQ Date: Thu, 19 Mar 2026 13:07:44 +0100 Subject: [PATCH 06/40] Moved from service to notify entity --- homeassistant/components/threema/README.md | 13 +- homeassistant/components/threema/__init__.py | 116 +-------- .../components/threema/config_flow.py | 64 ++++- homeassistant/components/threema/const.py | 2 + homeassistant/components/threema/icons.json | 5 - homeassistant/components/threema/notify.py | 76 ++++++ .../components/threema/quality_scale.yaml | 14 +- .../components/threema/services.yaml | 16 -- homeassistant/components/threema/strings.json | 58 ++--- tests/components/threema/conftest.py | 44 ++-- tests/components/threema/test_config_flow.py | 88 +++++++ tests/components/threema/test_init.py | 227 ------------------ tests/components/threema/test_notify.py | 201 ++++++++++++++++ 13 files changed, 499 insertions(+), 425 deletions(-) create mode 100644 homeassistant/components/threema/notify.py delete mode 100644 homeassistant/components/threema/services.yaml create mode 100644 tests/components/threema/test_notify.py diff --git a/homeassistant/components/threema/README.md b/homeassistant/components/threema/README.md index 7a8495d2ed6dd7..ed4a28a045afbf 100644 --- a/homeassistant/components/threema/README.md +++ b/homeassistant/components/threema/README.md @@ -6,9 +6,10 @@ User-facing documentation: https://www.home-assistant.io/integrations/threema ## Architecture -- **`__init__.py`** — Service registration (`threema.send_message`), config entry setup/unload, credential validation on startup +- **`__init__.py`** — Config entry setup/unload, credential validation on startup +- **`config_flow.py`** — Multi-step flow: setup type selection, optional key generation, credential entry with validation, reauthentication. Subentry flow for adding recipients. +- **`notify.py`** — `NotifyEntity` per recipient (configured via subentries). Sends text messages via the Threema SDK. - **`client.py`** — `ThreemaAPIClient` wrapping the [threema.gateway SDK](https://github.com/threema-ch/threema-msgapi-sdk-python). Handles E2E (`TextMessage`) and simple (`SimpleTextMessage`) modes. Custom exceptions: `ThreemaAuthError`, `ThreemaConnectionError`, `ThreemaSendError` -- **`config_flow.py`** — Multi-step flow: setup type selection, optional key generation, credential entry with validation, reauthentication - **`image.py`** — QR code image entity for gateway identity verification (encodes `3mid:,`) - **`const.py`** — Domain and config key constants @@ -19,10 +20,10 @@ User-facing documentation: https://www.home-assistant.io/integrations/threema ## Design Decisions -- **No notify entity** — `NotifyEntity.async_send_message` only accepts `message` and `title`, no recipient parameter. Threema always requires an explicit recipient, so the custom `threema.send_message` service is the proper interface. +- **NotifyEntity with subentries** — Each recipient is a subentry creating a `NotifyEntity`. This integrates with HA's notify groups (send to Threema + Telegram + Alexa in one action) and follows the platform-first architecture. - **Encryption downgrade protection** — Reauth flow preserves existing private keys when the field is left empty, preventing silent downgrade from E2E to simple mode. -- **Recipient validation** — Service schema validates Threema IDs with `^[0-9A-Za-z]{8}$` regex and normalizes to uppercase. -- **Multiple entry guard** — Auto-select is rejected when multiple entries are loaded; caller must specify `config_entry_id`. +- **Recipient validation** — Subentry flow validates Threema IDs with `^[0-9A-Za-z]{8}$` regex and normalizes to uppercase. +- **client.py as glue code** — The SDK handles all API communication. client.py maps SDK exceptions to HA-specific types and manages connection context. ## Quality Scale @@ -31,7 +32,7 @@ Silver. See `quality_scale.yaml` for full status. Gold blockers: `diagnostics` a ## Roadmap - Incoming messages via Gateway callback webhooks -- Image/file support via notify entity `data` parameter (camera entity snapshots, URLs, local files → Threema SDK `ImageMessage`) +- Image/file support (requires new platform or service — `NotifyEntity` only supports text + title) - Remaining credits sensor - Diagnostics platform - Reconfiguration flow diff --git a/homeassistant/components/threema/__init__.py b/homeassistant/components/threema/__init__.py index 84de66aacc012f..67ea46e176982c 100644 --- a/homeassistant/components/threema/__init__.py +++ b/homeassistant/components/threema/__init__.py @@ -4,126 +4,20 @@ import logging -import voluptuous as vol - -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, - ServiceValidationError, -) -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.typing import ConfigType +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from .client import ( - ThreemaAPIClient, - ThreemaAuthError, - ThreemaConnectionError, - ThreemaSendError, -) +from .client import ThreemaAPIClient, ThreemaAuthError, ThreemaConnectionError from .const import CONF_API_SECRET, CONF_GATEWAY_ID, CONF_PRIVATE_KEY, DOMAIN _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -PLATFORMS: list[Platform] = [Platform.IMAGE] +PLATFORMS: list[Platform] = [Platform.IMAGE, Platform.NOTIFY] type ThreemaConfigEntry = ConfigEntry[ThreemaAPIClient] -CONF_CONFIG_ENTRY_ID = "config_entry_id" -CONF_RECIPIENT = "recipient" -CONF_MESSAGE = "message" - -RECIPIENT_SCHEMA = vol.All( - cv.string, - cv.matches_regex(r"^[0-9A-Za-z]{8}$"), - lambda value: value.upper(), -) - -SERVICE_SEND_MESSAGE = "send_message" -SERVICE_SEND_MESSAGE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, - vol.Required(CONF_RECIPIENT): RECIPIENT_SCHEMA, - vol.Required(CONF_MESSAGE): cv.string, - } -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Threema Gateway component.""" - - async def async_send_message(call: ServiceCall) -> None: - """Handle the send_message service call.""" - recipient = call.data[CONF_RECIPIENT] - message = call.data[CONF_MESSAGE] - - # Get the config entry - auto-select if not specified - entry_id = call.data.get(CONF_CONFIG_ENTRY_ID) - - if entry_id: - entry = hass.config_entries.async_get_entry(entry_id) - if not entry or entry.domain != DOMAIN: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="entry_not_found", - ) - if entry.state is not ConfigEntryState.LOADED: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="entry_not_loaded", - ) - else: - # Auto-select: find any loaded Threema config entry - entries = [ - e - for e in hass.config_entries.async_entries(DOMAIN) - if e.state is ConfigEntryState.LOADED - ] - if not entries: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_entries_found", - ) - if len(entries) > 1: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="multiple_entries_found", - ) - entry = entries[0] - - # Send the message - client: ThreemaAPIClient = entry.runtime_data - try: - await client.send_text_message(recipient, message) - except ThreemaAuthError as err: - _LOGGER.warning( - "Authentication failed sending message; check your Gateway credentials" - ) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="send_error", - translation_placeholders={"error": str(err)}, - ) from err - except (ThreemaSendError, ThreemaConnectionError) as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="send_error", - translation_placeholders={"error": str(err)}, - ) from err - - hass.services.async_register( - DOMAIN, - SERVICE_SEND_MESSAGE, - async_send_message, - schema=SERVICE_SEND_MESSAGE_SCHEMA, - ) - - return True - async def async_setup_entry(hass: HomeAssistant, entry: ThreemaConfigEntry) -> bool: """Set up Threema Gateway from a config entry.""" diff --git a/homeassistant/components/threema/config_flow.py b/homeassistant/components/threema/config_flow.py index a04f7a5fcf3be0..4157874353549e 100644 --- a/homeassistant/components/threema/config_flow.py +++ b/homeassistant/components/threema/config_flow.py @@ -8,7 +8,14 @@ import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.helpers import config_validation as cv from .client import ( ThreemaAPIClient, @@ -21,7 +28,9 @@ CONF_GATEWAY_ID, CONF_PRIVATE_KEY, CONF_PUBLIC_KEY, + CONF_RECIPIENT, DOMAIN, + SUBENTRY_TYPE_RECIPIENT, ) _LOGGER = logging.getLogger(__name__) @@ -31,7 +40,14 @@ class ThreemaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Threema Gateway.""" VERSION = 1 - MINOR_VERSION = 1 + MINOR_VERSION = 2 + + @classmethod + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentry types supported by this integration.""" + return {SUBENTRY_TYPE_RECIPIENT: RecipientSubentryFlowHandler} _gateway_id: str | None = None _api_secret: str | None = None @@ -214,3 +230,47 @@ async def async_step_reauth_confirm( ), errors=errors, ) + + +RECIPIENT_SCHEMA = vol.All( + cv.string, + cv.matches_regex(r"^[0-9A-Za-z]{8}$"), + lambda value: value.upper(), +) + + +class RecipientSubentryFlowHandler(ConfigSubentryFlow): + """Handle adding a Threema recipient as a subentry.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle the recipient subentry step.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + recipient_id = RECIPIENT_SCHEMA(user_input[CONF_RECIPIENT]) + except vol.Invalid: + errors["base"] = "invalid_recipient_id" + else: + # Check for duplicate recipients + for subentry in self._get_entry().subentries.values(): + if subentry.data.get(CONF_RECIPIENT) == recipient_id: + return self.async_abort(reason="already_configured") + + return self.async_create_entry( + title=recipient_id, + data={CONF_RECIPIENT: recipient_id}, + unique_id=recipient_id, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_RECIPIENT): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/threema/const.py b/homeassistant/components/threema/const.py index 9cb071c61ac0b0..ffd5c68c5a669d 100644 --- a/homeassistant/components/threema/const.py +++ b/homeassistant/components/threema/const.py @@ -6,3 +6,5 @@ CONF_API_SECRET = "api_secret" CONF_PRIVATE_KEY = "private_key" CONF_PUBLIC_KEY = "public_key" +CONF_RECIPIENT = "recipient" +SUBENTRY_TYPE_RECIPIENT = "recipient" diff --git a/homeassistant/components/threema/icons.json b/homeassistant/components/threema/icons.json index 73b4c06ca735ae..2c63c0851048d8 100644 --- a/homeassistant/components/threema/icons.json +++ b/homeassistant/components/threema/icons.json @@ -1,7 +1,2 @@ { - "services": { - "send_message": { - "service": "mdi:message-text-lock" - } - } } diff --git a/homeassistant/components/threema/notify.py b/homeassistant/components/threema/notify.py new file mode 100644 index 00000000000000..bc46d5ce3158d8 --- /dev/null +++ b/homeassistant/components/threema/notify.py @@ -0,0 +1,76 @@ +"""Notify platform for Threema Gateway integration.""" + +from __future__ import annotations + +import logging + +from homeassistant.components.notify import NotifyEntity, NotifyEntityFeature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ThreemaConfigEntry +from .client import ThreemaAuthError, ThreemaConnectionError, ThreemaSendError +from .const import CONF_GATEWAY_ID, CONF_RECIPIENT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ThreemaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Threema notify entities from config entry subentries.""" + for subentry_id, subentry in entry.subentries.items(): + async_add_entities( + [ThreemaNotifyEntity(entry, subentry_id, subentry)], + config_subentry_id=subentry_id, + ) + + +class ThreemaNotifyEntity(NotifyEntity): + """Notify entity for sending messages to a Threema recipient.""" + + _attr_has_entity_name = True + _attr_supported_features = NotifyEntityFeature.TITLE + + def __init__( + self, + entry: ThreemaConfigEntry, + subentry_id: str, + subentry: object, + ) -> None: + """Initialize the notify entity.""" + self._entry = entry + self._recipient_id: str = subentry.data[CONF_RECIPIENT] + gateway_id = entry.data[CONF_GATEWAY_ID] + + self._attr_unique_id = f"{gateway_id}_{subentry_id}" + self._attr_name = subentry.title + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, gateway_id)}, + ) + + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Send a message to the configured Threema recipient.""" + text = f"*{title}*\n{message}" if title else message + client = self._entry.runtime_data + try: + await client.send_text_message(self._recipient_id, text) + except ThreemaAuthError as err: + _LOGGER.warning( + "Authentication failed sending message; check your Gateway credentials" + ) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_error", + translation_placeholders={"error": str(err)}, + ) from err + except (ThreemaSendError, ThreemaConnectionError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_error", + translation_placeholders={"error": str(err)}, + ) from err diff --git a/homeassistant/components/threema/quality_scale.yaml b/homeassistant/components/threema/quality_scale.yaml index b241020eebf144..9515274a4f99b7 100644 --- a/homeassistant/components/threema/quality_scale.yaml +++ b/homeassistant/components/threema/quality_scale.yaml @@ -1,8 +1,6 @@ rules: # Bronze - action-setup: - status: exempt - comment: Integration does not register entity actions. + action-setup: done appropriate-polling: status: exempt comment: Integration only sends messages, no polling required. @@ -11,9 +9,7 @@ rules: config-flow: done config-flow-test-coverage: done dependency-transparency: done - docs-actions: - status: exempt - comment: Integration does not provide entity actions. + docs-actions: done docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done @@ -28,9 +24,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: Integration does not register entity actions. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done @@ -73,7 +67,7 @@ rules: comment: QR code image entity is a primary security verification feature, no diagnostic or config category applies. entity-device-class: status: exempt - comment: No applicable device class for QR code image entity. + comment: No applicable device class for QR code image or notify entities. entity-disabled-by-default: status: exempt comment: QR code entity is used for gateway identity verification and should be visible by default. diff --git a/homeassistant/components/threema/services.yaml b/homeassistant/components/threema/services.yaml deleted file mode 100644 index 3a414944956a5f..00000000000000 --- a/homeassistant/components/threema/services.yaml +++ /dev/null @@ -1,16 +0,0 @@ -send_message: - fields: - config_entry_id: - required: false - selector: - config_entry: - integration: threema - recipient: - required: true - selector: - text: - message: - required: true - selector: - text: - multiline: true diff --git a/homeassistant/components/threema/strings.json b/homeassistant/components/threema/strings.json index 17d497754aabd2..a3213174f24fa3 100644 --- a/homeassistant/components/threema/strings.json +++ b/homeassistant/components/threema/strings.json @@ -64,6 +64,32 @@ } } }, + "config_subentries": { + "recipient": { + "abort": { + "already_configured": "This recipient is already configured." + }, + "entry_type": "Recipient", + "error": { + "invalid_recipient_id": "Threema ID must be exactly 8 alphanumeric characters." + }, + "initiate_flow": { + "user": "Add recipient" + }, + "step": { + "user": { + "data": { + "recipient": "Threema ID" + }, + "data_description": { + "recipient": "The 8-character Threema ID of the recipient." + }, + "description": "Enter the Threema ID of the person you want to send messages to.", + "title": "Add recipient" + } + } + } + }, "entity": { "image": { "gateway_qr_code": { @@ -75,43 +101,11 @@ "cannot_connect": { "message": "[%key:common::config_flow::error::cannot_connect%]" }, - "entry_not_found": { - "message": "Config entry not found." - }, - "entry_not_loaded": { - "message": "Config entry is not loaded." - }, "invalid_auth": { "message": "[%key:common::config_flow::error::invalid_auth%]" }, - "multiple_entries_found": { - "message": "Multiple Threema integrations configured. Please specify a config_entry_id." - }, - "no_entries_found": { - "message": "No Threema integration configured or loaded." - }, "send_error": { "message": "Error sending message: {error}" } - }, - "services": { - "send_message": { - "description": "Sends a text message via Threema Gateway.", - "fields": { - "config_entry_id": { - "description": "The Threema Gateway config entry to use.", - "name": "Config entry" - }, - "message": { - "description": "The text message to send.", - "name": "Message" - }, - "recipient": { - "description": "Threema ID of the recipient (8 characters).", - "name": "Recipient" - } - }, - "name": "Send message" - } } } diff --git a/tests/components/threema/conftest.py b/tests/components/threema/conftest.py index 145483df8af8cf..c3dbf4327e2a57 100644 --- a/tests/components/threema/conftest.py +++ b/tests/components/threema/conftest.py @@ -12,11 +12,12 @@ CONF_GATEWAY_ID, CONF_PRIVATE_KEY, CONF_PUBLIC_KEY, + CONF_RECIPIENT, DOMAIN, + SUBENTRY_TYPE_RECIPIENT, ) -from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, MockConfigEntrySubentry MOCK_GATEWAY_ID = "*TESTGWY" MOCK_API_SECRET = "test_secret_key_12345" @@ -26,6 +27,8 @@ MOCK_PUBLIC_KEY = ( "public:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" ) +MOCK_RECIPIENT_ID = "ABCD1234" +MOCK_SUBENTRY_ID = "mock_subentry_id" @pytest.fixture @@ -58,6 +61,29 @@ def mock_config_entry_with_keys() -> MockConfigEntry: ) +@pytest.fixture +def mock_config_entry_with_subentry() -> MockConfigEntry: + """Return a mocked config entry with a recipient subentry.""" + return MockConfigEntry( + title=f"Threema {MOCK_GATEWAY_ID}", + domain=DOMAIN, + data={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + unique_id=MOCK_GATEWAY_ID, + subentries_data=[ + MockConfigEntrySubentry( + data={CONF_RECIPIENT: MOCK_RECIPIENT_ID}, + subentry_id=MOCK_SUBENTRY_ID, + subentry_type=SUBENTRY_TYPE_RECIPIENT, + title=MOCK_RECIPIENT_ID, + unique_id=MOCK_RECIPIENT_ID, + ), + ], + ) + + @pytest.fixture def mock_connection() -> Generator[MagicMock]: """Mock the Threema Gateway Connection.""" @@ -92,17 +118,3 @@ def mock_send() -> Generator[MagicMock]: simple_mock.return_value = simple_instance yield MagicMock(e2e=e2e_mock, simple=simple_mock) - - -@pytest.fixture -async def init_integration( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_connection: MagicMock, - mock_send: MagicMock, -) -> MockConfigEntry: - """Set up the Threema integration for testing.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - return mock_config_entry diff --git a/tests/components/threema/test_config_flow.py b/tests/components/threema/test_config_flow.py index 32009fbebd535c..4c7ed60b7f4b79 100644 --- a/tests/components/threema/test_config_flow.py +++ b/tests/components/threema/test_config_flow.py @@ -13,7 +13,9 @@ CONF_GATEWAY_ID, CONF_PRIVATE_KEY, CONF_PUBLIC_KEY, + CONF_RECIPIENT, DOMAIN, + SUBENTRY_TYPE_RECIPIENT, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -378,3 +380,89 @@ async def test_reauth_flow_cannot_connect( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} + + +async def test_subentry_add_recipient( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: MagicMock, +) -> None: + """Test adding a recipient via subentry flow.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_RECIPIENT), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: "ABCD1234"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "ABCD1234" + assert result["data"] == {CONF_RECIPIENT: "ABCD1234"} + + +async def test_subentry_invalid_recipient_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: MagicMock, +) -> None: + """Test subentry flow rejects invalid Threema ID.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_RECIPIENT), + context={"source": config_entries.SOURCE_USER}, + ) + + # Too short + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: "ABC"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_recipient_id"} + + # Special characters + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: "ABCD!@#$"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_recipient_id"} + + +async def test_subentry_duplicate_recipient( + hass: HomeAssistant, + mock_config_entry_with_subentry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: MagicMock, +) -> None: + """Test subentry flow rejects duplicate recipient.""" + mock_config_entry_with_subentry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_subentry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry_with_subentry.entry_id, SUBENTRY_TYPE_RECIPIENT), + context={"source": config_entries.SOURCE_USER}, + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: "ABCD1234"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/threema/test_init.py b/tests/components/threema/test_init.py index d82f1d9c18e020..cb7283ca4b0dfb 100644 --- a/tests/components/threema/test_init.py +++ b/tests/components/threema/test_init.py @@ -4,19 +4,9 @@ from unittest.mock import AsyncMock, MagicMock, patch -import pytest -from threema.gateway.exception import GatewayServerError - from homeassistant.components.threema.client import ThreemaAuthError -from homeassistant.components.threema.const import ( - CONF_API_SECRET, - CONF_GATEWAY_ID, - DOMAIN, -) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -91,220 +81,3 @@ async def test_unload_entry( await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - - -async def test_send_message_service( - hass: HomeAssistant, - init_integration: MockConfigEntry, - mock_send: MagicMock, -) -> None: - """Test the send_message service call.""" - await hass.services.async_call( - DOMAIN, - "send_message", - { - "config_entry_id": init_integration.entry_id, - "recipient": "ABCD1234", - "message": "Hello from tests!", - }, - blocking=True, - ) - - # Verify SimpleTextMessage was used (no private key) - mock_send.simple.assert_called_once() - - -async def test_send_message_service_auto_select( - hass: HomeAssistant, - init_integration: MockConfigEntry, - mock_send: MagicMock, -) -> None: - """Test send_message service auto-selects entry when not specified.""" - await hass.services.async_call( - DOMAIN, - "send_message", - { - "recipient": "ABCD1234", - "message": "Hello from tests!", - }, - blocking=True, - ) - - mock_send.simple.assert_called_once() - - -async def test_send_message_service_entry_not_found( - hass: HomeAssistant, - init_integration: MockConfigEntry, -) -> None: - """Test send_message service with invalid entry ID.""" - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - "send_message", - { - "config_entry_id": "nonexistent_entry_id", - "recipient": "ABCD1234", - "message": "Hello!", - }, - blocking=True, - ) - - -async def test_send_message_service_no_loaded_entries( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_connection: MagicMock, - mock_send: MagicMock, -) -> None: - """Test send_message service raises when no entries are loaded.""" - mock_config_entry.add_to_hass(hass) - - # Setup then unload so service is registered but no entry is loaded - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - await hass.config_entries.async_unload(mock_config_entry.entry_id) - await hass.async_block_till_done() - - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - "send_message", - { - "recipient": "ABCD1234", - "message": "Hello!", - }, - blocking=True, - ) - - -async def test_send_message_service_send_error( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_connection: MagicMock, -) -> None: - """Test send_message service handles send failure.""" - mock_config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.threema.client.SimpleTextMessage", autospec=True - ) as simple_mock: - simple_instance = MagicMock() - simple_instance.send = AsyncMock(side_effect=Exception("Network error")) - simple_mock.return_value = simple_instance - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - DOMAIN, - "send_message", - { - "recipient": "ABCD1234", - "message": "Hello!", - }, - blocking=True, - ) - - -async def test_send_message_service_auth_error( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_connection: MagicMock, -) -> None: - """Test send_message service handles auth failure during send.""" - mock_config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.threema.client.SimpleTextMessage", autospec=True - ) as simple_mock: - simple_instance = MagicMock() - simple_instance.send = AsyncMock(side_effect=GatewayServerError(status=401)) - simple_mock.return_value = simple_instance - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - DOMAIN, - "send_message", - { - "recipient": "ABCD1234", - "message": "Hello!", - }, - blocking=True, - ) - - -async def test_send_message_service_e2e( - hass: HomeAssistant, - mock_config_entry_with_keys: MockConfigEntry, - mock_connection: MagicMock, - mock_send: MagicMock, -) -> None: - """Test send_message service uses E2E TextMessage when private key is set.""" - mock_config_entry_with_keys.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry_with_keys.entry_id) - await hass.async_block_till_done() - - await hass.services.async_call( - DOMAIN, - "send_message", - { - "config_entry_id": mock_config_entry_with_keys.entry_id, - "recipient": "ABCD1234", - "message": "Hello E2E!", - }, - blocking=True, - ) - - mock_send.e2e.assert_called_once() - - -async def test_send_message_service_multiple_entries( - hass: HomeAssistant, - mock_connection: MagicMock, - mock_send: MagicMock, -) -> None: - """Test send_message service raises when multiple entries and no ID specified.""" - entry1 = MockConfigEntry( - title="Threema *FIRST01", - domain=DOMAIN, - data={ - CONF_GATEWAY_ID: "*FIRST01", - CONF_API_SECRET: "first_secret", - }, - unique_id="*FIRST01", - ) - entry2 = MockConfigEntry( - title="Threema *SECOND1", - domain=DOMAIN, - data={ - CONF_GATEWAY_ID: "*SECOND1", - CONF_API_SECRET: "second_secret", - }, - unique_id="*SECOND1", - ) - entry1.add_to_hass(hass) - entry2.add_to_hass(hass) - - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - assert entry1.state is ConfigEntryState.LOADED - assert entry2.state is ConfigEntryState.LOADED - - with pytest.raises(ServiceValidationError) as exc_info: - await hass.services.async_call( - DOMAIN, - "send_message", - { - "recipient": "ABCD1234", - "message": "Hello!", - }, - blocking=True, - ) - - assert exc_info.value.translation_key == "multiple_entries_found" diff --git a/tests/components/threema/test_notify.py b/tests/components/threema/test_notify.py new file mode 100644 index 00000000000000..5d970fd6de8973 --- /dev/null +++ b/tests/components/threema/test_notify.py @@ -0,0 +1,201 @@ +"""Test the Threema Gateway notify platform.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from threema.gateway.exception import GatewayServerError + +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.components.threema.const import ( + CONF_RECIPIENT, + SUBENTRY_TYPE_RECIPIENT, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .conftest import MOCK_GATEWAY_ID, MOCK_RECIPIENT_ID, MOCK_SUBENTRY_ID + +from tests.common import MockConfigEntry, MockConfigEntrySubentry + + +async def test_notify_entity_created( + hass: HomeAssistant, + mock_config_entry_with_subentry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: MagicMock, +) -> None: + """Test notify entity is created from subentry.""" + mock_config_entry_with_subentry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_subentry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry_with_subentry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + assert len(notify_entities) == 1 + assert notify_entities[0].unique_id == f"{MOCK_GATEWAY_ID}_{MOCK_SUBENTRY_ID}" + + +async def test_notify_entity_not_created_without_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: MagicMock, +) -> None: + """Test no notify entity without subentries.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + assert len(notify_entities) == 0 + + +async def test_send_message_simple( + hass: HomeAssistant, + mock_config_entry_with_subentry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: MagicMock, +) -> None: + """Test sending a message via notify entity (simple mode).""" + mock_config_entry_with_subentry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_subentry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry_with_subentry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + assert len(notify_entities) == 1 + + await hass.services.async_call( + NOTIFY_DOMAIN, + "send_message", + {"entity_id": notify_entities[0].entity_id, "message": "Hello from tests!"}, + blocking=True, + ) + + mock_send.simple.assert_called_once() + + +async def test_send_message_e2e( + hass: HomeAssistant, + mock_config_entry_with_keys: MockConfigEntry, + mock_connection: MagicMock, + mock_send: MagicMock, +) -> None: + """Test sending a message via notify entity (E2E mode).""" + entry = MockConfigEntry( + title=f"Threema {MOCK_GATEWAY_ID}", + domain="threema", + data=mock_config_entry_with_keys.data, + unique_id=MOCK_GATEWAY_ID, + subentries_data=[ + MockConfigEntrySubentry( + data={CONF_RECIPIENT: MOCK_RECIPIENT_ID}, + subentry_id=MOCK_SUBENTRY_ID, + subentry_type=SUBENTRY_TYPE_RECIPIENT, + title=MOCK_RECIPIENT_ID, + unique_id=MOCK_RECIPIENT_ID, + ), + ], + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + assert len(notify_entities) == 1 + + await hass.services.async_call( + NOTIFY_DOMAIN, + "send_message", + {"entity_id": notify_entities[0].entity_id, "message": "Hello E2E!"}, + blocking=True, + ) + + mock_send.e2e.assert_called_once() + + +async def test_send_message_auth_error( + hass: HomeAssistant, + mock_config_entry_with_subentry: MockConfigEntry, + mock_connection: MagicMock, +) -> None: + """Test notify entity handles auth failure during send.""" + mock_config_entry_with_subentry.add_to_hass(hass) + + with patch( + "homeassistant.components.threema.client.SimpleTextMessage", autospec=True + ) as simple_mock: + simple_instance = MagicMock() + simple_instance.send = AsyncMock(side_effect=GatewayServerError(status=401)) + simple_mock.return_value = simple_instance + + await hass.config_entries.async_setup(mock_config_entry_with_subentry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry_with_subentry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NOTIFY_DOMAIN, + "send_message", + { + "entity_id": notify_entities[0].entity_id, + "message": "Hello!", + }, + blocking=True, + ) + + +async def test_send_message_send_error( + hass: HomeAssistant, + mock_config_entry_with_subentry: MockConfigEntry, + mock_connection: MagicMock, +) -> None: + """Test notify entity handles send failure.""" + mock_config_entry_with_subentry.add_to_hass(hass) + + with patch( + "homeassistant.components.threema.client.SimpleTextMessage", autospec=True + ) as simple_mock: + simple_instance = MagicMock() + simple_instance.send = AsyncMock(side_effect=Exception("Network error")) + simple_mock.return_value = simple_instance + + await hass.config_entries.async_setup(mock_config_entry_with_subentry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry_with_subentry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NOTIFY_DOMAIN, + "send_message", + { + "entity_id": notify_entities[0].entity_id, + "message": "Hello!", + }, + blocking=True, + ) From b03b8f0e92ed557de9c6d926b879203cd545576a Mon Sep 17 00:00:00 2001 From: LukasQ Date: Thu, 19 Mar 2026 13:26:16 +0100 Subject: [PATCH 07/40] removed ln and changed subentry tests --- homeassistant/components/threema/icons.json | 3 +-- homeassistant/components/threema/notify.py | 3 ++- tests/components/threema/conftest.py | 16 ++++++++-------- tests/components/threema/test_notify.py | 16 ++++++++-------- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/threema/icons.json b/homeassistant/components/threema/icons.json index 2c63c0851048d8..9e26dfeeb6e641 100644 --- a/homeassistant/components/threema/icons.json +++ b/homeassistant/components/threema/icons.json @@ -1,2 +1 @@ -{ -} +{} \ No newline at end of file diff --git a/homeassistant/components/threema/notify.py b/homeassistant/components/threema/notify.py index bc46d5ce3158d8..c7372a2e02f70b 100644 --- a/homeassistant/components/threema/notify.py +++ b/homeassistant/components/threema/notify.py @@ -5,6 +5,7 @@ import logging from homeassistant.components.notify import NotifyEntity, NotifyEntityFeature +from homeassistant.config_entries import ConfigSubentry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr @@ -40,7 +41,7 @@ def __init__( self, entry: ThreemaConfigEntry, subentry_id: str, - subentry: object, + subentry: ConfigSubentry, ) -> None: """Initialize the notify entity.""" self._entry = entry diff --git a/tests/components/threema/conftest.py b/tests/components/threema/conftest.py index c3dbf4327e2a57..03c4a0da8ddb7f 100644 --- a/tests/components/threema/conftest.py +++ b/tests/components/threema/conftest.py @@ -17,7 +17,7 @@ SUBENTRY_TYPE_RECIPIENT, ) -from tests.common import MockConfigEntry, MockConfigEntrySubentry +from tests.common import MockConfigEntry MOCK_GATEWAY_ID = "*TESTGWY" MOCK_API_SECRET = "test_secret_key_12345" @@ -73,13 +73,13 @@ def mock_config_entry_with_subentry() -> MockConfigEntry: }, unique_id=MOCK_GATEWAY_ID, subentries_data=[ - MockConfigEntrySubentry( - data={CONF_RECIPIENT: MOCK_RECIPIENT_ID}, - subentry_id=MOCK_SUBENTRY_ID, - subentry_type=SUBENTRY_TYPE_RECIPIENT, - title=MOCK_RECIPIENT_ID, - unique_id=MOCK_RECIPIENT_ID, - ), + { + "data": {CONF_RECIPIENT: MOCK_RECIPIENT_ID}, + "subentry_id": MOCK_SUBENTRY_ID, + "subentry_type": SUBENTRY_TYPE_RECIPIENT, + "title": MOCK_RECIPIENT_ID, + "unique_id": MOCK_RECIPIENT_ID, + }, ], ) diff --git a/tests/components/threema/test_notify.py b/tests/components/threema/test_notify.py index 5d970fd6de8973..d6e3f51be7d0eb 100644 --- a/tests/components/threema/test_notify.py +++ b/tests/components/threema/test_notify.py @@ -18,7 +18,7 @@ from .conftest import MOCK_GATEWAY_ID, MOCK_RECIPIENT_ID, MOCK_SUBENTRY_ID -from tests.common import MockConfigEntry, MockConfigEntrySubentry +from tests.common import MockConfigEntry async def test_notify_entity_created( @@ -101,13 +101,13 @@ async def test_send_message_e2e( data=mock_config_entry_with_keys.data, unique_id=MOCK_GATEWAY_ID, subentries_data=[ - MockConfigEntrySubentry( - data={CONF_RECIPIENT: MOCK_RECIPIENT_ID}, - subentry_id=MOCK_SUBENTRY_ID, - subentry_type=SUBENTRY_TYPE_RECIPIENT, - title=MOCK_RECIPIENT_ID, - unique_id=MOCK_RECIPIENT_ID, - ), + { + "data": {CONF_RECIPIENT: MOCK_RECIPIENT_ID}, + "subentry_id": MOCK_SUBENTRY_ID, + "subentry_type": SUBENTRY_TYPE_RECIPIENT, + "title": MOCK_RECIPIENT_ID, + "unique_id": MOCK_RECIPIENT_ID, + }, ], ) entry.add_to_hass(hass) From eae23867b9b5bcceacf8098cea9617f91867a572 Mon Sep 17 00:00:00 2001 From: LukasQ Date: Thu, 19 Mar 2026 13:30:34 +0100 Subject: [PATCH 08/40] Replaced icons by standarrd ones --- homeassistant/components/threema/icons.json | 1 - homeassistant/components/threema/quality_scale.yaml | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) delete mode 100644 homeassistant/components/threema/icons.json diff --git a/homeassistant/components/threema/icons.json b/homeassistant/components/threema/icons.json deleted file mode 100644 index 9e26dfeeb6e641..00000000000000 --- a/homeassistant/components/threema/icons.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/homeassistant/components/threema/quality_scale.yaml b/homeassistant/components/threema/quality_scale.yaml index 9515274a4f99b7..a1732fb5c3766d 100644 --- a/homeassistant/components/threema/quality_scale.yaml +++ b/homeassistant/components/threema/quality_scale.yaml @@ -73,7 +73,9 @@ rules: comment: QR code entity is used for gateway identity verification and should be visible by default. entity-translations: done exception-translations: done - icon-translations: done + icon-translations: + status: exempt + comment: No custom icons defined. Notify and image entities use default platform icons. reconfiguration-flow: todo repair-issues: status: exempt From bcd275fecc44f8a7f5e45f18b3ee2f255d163151 Mon Sep 17 00:00:00 2001 From: LukasQ Date: Thu, 19 Mar 2026 13:53:41 +0100 Subject: [PATCH 09/40] moved qr code pub key verification to a new feature --- homeassistant/components/threema/README.md | 7 +- homeassistant/components/threema/__init__.py | 2 +- homeassistant/components/threema/image.py | 97 ------------------- .../components/threema/manifest.json | 4 +- homeassistant/components/threema/notify.py | 4 - .../components/threema/quality_scale.yaml | 24 +++-- homeassistant/components/threema/strings.json | 7 -- requirements_all.txt | 3 - requirements_test_all.txt | 3 - tests/components/threema/test_image.py | 94 ------------------ 10 files changed, 20 insertions(+), 225 deletions(-) delete mode 100644 homeassistant/components/threema/image.py delete mode 100644 tests/components/threema/test_image.py diff --git a/homeassistant/components/threema/README.md b/homeassistant/components/threema/README.md index ed4a28a045afbf..17705ee0e852d1 100644 --- a/homeassistant/components/threema/README.md +++ b/homeassistant/components/threema/README.md @@ -8,15 +8,13 @@ User-facing documentation: https://www.home-assistant.io/integrations/threema - **`__init__.py`** — Config entry setup/unload, credential validation on startup - **`config_flow.py`** — Multi-step flow: setup type selection, optional key generation, credential entry with validation, reauthentication. Subentry flow for adding recipients. -- **`notify.py`** — `NotifyEntity` per recipient (configured via subentries). Sends text messages via the Threema SDK. +- **`notify.py`** — `NotifyEntity` per recipient (configured via subentries). Sends text messages via the Threema SDK. Supports optional title (prepended as bold text). - **`client.py`** — `ThreemaAPIClient` wrapping the [threema.gateway SDK](https://github.com/threema-ch/threema-msgapi-sdk-python). Handles E2E (`TextMessage`) and simple (`SimpleTextMessage`) modes. Custom exceptions: `ThreemaAuthError`, `ThreemaConnectionError`, `ThreemaSendError` -- **`image.py`** — QR code image entity for gateway identity verification (encodes `3mid:,`) - **`const.py`** — Domain and config key constants ## Dependencies - `threema.gateway==8.0.0` — [Official Threema Gateway SDK](https://github.com/threema-ch/threema-msgapi-sdk-python) (MIT) -- `qrcode==8.2` — QR code generation for identity verification ## Design Decisions @@ -27,10 +25,11 @@ User-facing documentation: https://www.home-assistant.io/integrations/threema ## Quality Scale -Silver. See `quality_scale.yaml` for full status. Gold blockers: `diagnostics` and `reconfiguration-flow`. +Silver. See `quality_scale.yaml` for full status. ## Roadmap +- QR code image entity for gateway identity verification (follow-up PR, branch `feature/threema-qr`) - Incoming messages via Gateway callback webhooks - Image/file support (requires new platform or service — `NotifyEntity` only supports text + title) - Remaining credits sensor diff --git a/homeassistant/components/threema/__init__.py b/homeassistant/components/threema/__init__.py index 67ea46e176982c..354d503eeafafa 100644 --- a/homeassistant/components/threema/__init__.py +++ b/homeassistant/components/threema/__init__.py @@ -14,7 +14,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.IMAGE, Platform.NOTIFY] +PLATFORMS: list[Platform] = [Platform.NOTIFY] type ThreemaConfigEntry = ConfigEntry[ThreemaAPIClient] diff --git a/homeassistant/components/threema/image.py b/homeassistant/components/threema/image.py deleted file mode 100644 index 5a12beccea4a45..00000000000000 --- a/homeassistant/components/threema/image.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Image platform for Threema Gateway integration.""" - -from __future__ import annotations - -from datetime import UTC, datetime -from io import BytesIO -import logging - -import qrcode - -from homeassistant.components.image import ImageEntity -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from . import ThreemaConfigEntry -from .const import CONF_GATEWAY_ID, CONF_PUBLIC_KEY, DOMAIN - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ThreemaConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up Threema image entities from a config entry.""" - if entry.data.get(CONF_PUBLIC_KEY): - async_add_entities([ThreemaQRCodeImage(hass, entry)]) - - -class ThreemaQRCodeImage(ImageEntity): - """Image entity that displays the gateway's public key as a QR code.""" - - _attr_has_entity_name = True - _attr_translation_key = "gateway_qr_code" - _attr_content_type = "image/png" - - def __init__(self, hass: HomeAssistant, entry: ThreemaConfigEntry) -> None: - """Initialize the QR code image entity.""" - super().__init__(hass) - self._entry = entry - self._qr_image_bytes: bytes | None = None - - gateway_id = entry.data[CONF_GATEWAY_ID] - self._attr_unique_id = f"{gateway_id}_qr_code" - self._attr_device_info = dr.DeviceInfo( - identifiers={(DOMAIN, gateway_id)}, - name=f"Threema {gateway_id}", - manufacturer="Threema", - model="Gateway", - configuration_url="https://gateway.threema.ch", - ) - - async def async_added_to_hass(self) -> None: - """Generate QR code when entity is added to hass.""" - await super().async_added_to_hass() - try: - await self.hass.async_add_executor_job(self._generate_qr_code) - except Exception: - _LOGGER.exception("Error generating Threema gateway QR code") - return - if self._qr_image_bytes is not None: - self._attr_image_last_updated = datetime.now(UTC) - - def _generate_qr_code(self) -> None: - """Generate QR code from the public key.""" - public_key = self._entry.data.get(CONF_PUBLIC_KEY) - if not public_key: - return - - gateway_id = self._entry.data[CONF_GATEWAY_ID] - public_key_hex = public_key.replace("public:", "").strip().lower() - qr_data = f"3mid:{gateway_id},{public_key_hex}" - - qr = qrcode.QRCode( - version=1, - error_correction=qrcode.constants.ERROR_CORRECT_L, - box_size=10, - border=4, - ) - qr.add_data(qr_data) - qr.make(fit=True) - - img = qr.make_image(fill_color="black", back_color="white") - buffer = BytesIO() - img.save(buffer, format="PNG") - self._qr_image_bytes = buffer.getvalue() - - @property - def available(self) -> bool: - """Return if entity is available.""" - return self._qr_image_bytes is not None - - async def async_image(self) -> bytes | None: - """Return the image bytes.""" - return self._qr_image_bytes diff --git a/homeassistant/components/threema/manifest.json b/homeassistant/components/threema/manifest.json index b4f848272e2abe..1840c83e6dbe7e 100644 --- a/homeassistant/components/threema/manifest.json +++ b/homeassistant/components/threema/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/threema", "integration_type": "service", "iot_class": "cloud_push", - "loggers": ["qrcode", "threema"], + "loggers": ["threema"], "quality_scale": "silver", - "requirements": ["qrcode==8.2", "threema.gateway==8.0.0"] + "requirements": ["threema.gateway==8.0.0"] } diff --git a/homeassistant/components/threema/notify.py b/homeassistant/components/threema/notify.py index c7372a2e02f70b..2631707b3aa515 100644 --- a/homeassistant/components/threema/notify.py +++ b/homeassistant/components/threema/notify.py @@ -8,7 +8,6 @@ from homeassistant.config_entries import ConfigSubentry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ThreemaConfigEntry @@ -50,9 +49,6 @@ def __init__( self._attr_unique_id = f"{gateway_id}_{subentry_id}" self._attr_name = subentry.title - self._attr_device_info = dr.DeviceInfo( - identifiers={(DOMAIN, gateway_id)}, - ) async def async_send_message(self, message: str, title: str | None = None) -> None: """Send a message to the configured Threema recipient.""" diff --git a/homeassistant/components/threema/quality_scale.yaml b/homeassistant/components/threema/quality_scale.yaml index a1732fb5c3766d..e941badeee2450 100644 --- a/homeassistant/components/threema/quality_scale.yaml +++ b/homeassistant/components/threema/quality_scale.yaml @@ -28,19 +28,23 @@ rules: config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done - entity-unavailable: done + entity-unavailable: + status: exempt + comment: Notify entities are stateless send-only, availability is not applicable. integration-owner: done log-when-unavailable: status: exempt - comment: Image entity availability is based on local QR code generation, not external service state. + comment: Notify entities are stateless send-only. parallel-updates: status: exempt - comment: Image entity is static (generated once), no parallel update concerns. + comment: No polling or data updates. reauthentication-flow: done test-coverage: done # Gold - devices: done + devices: + status: exempt + comment: Notify entities do not require a device. Gateway device will be added with QR code image platform in a follow-up. diagnostics: todo discovery: status: exempt @@ -61,25 +65,25 @@ rules: docs-use-cases: done dynamic-devices: status: exempt - comment: Single static gateway device per config entry. + comment: No devices in current scope. entity-category: status: exempt - comment: QR code image entity is a primary security verification feature, no diagnostic or config category applies. + comment: Notify entities have no applicable category. entity-device-class: status: exempt - comment: No applicable device class for QR code image or notify entities. + comment: No applicable device class for notify entities. entity-disabled-by-default: status: exempt - comment: QR code entity is used for gateway identity verification and should be visible by default. + comment: Notify entities should be enabled by default for immediate use. entity-translations: done exception-translations: done icon-translations: status: exempt - comment: No custom icons defined. Notify and image entities use default platform icons. + comment: Notify entities use default platform icon. reconfiguration-flow: todo repair-issues: status: exempt comment: No scenarios requiring repair issues. stale-devices: status: exempt - comment: Single static device per config entry. + comment: No devices in current scope. diff --git a/homeassistant/components/threema/strings.json b/homeassistant/components/threema/strings.json index a3213174f24fa3..23cfb3bdf3ebac 100644 --- a/homeassistant/components/threema/strings.json +++ b/homeassistant/components/threema/strings.json @@ -90,13 +90,6 @@ } } }, - "entity": { - "image": { - "gateway_qr_code": { - "name": "Gateway QR code" - } - } - }, "exceptions": { "cannot_connect": { "message": "[%key:common::config_flow::error::cannot_connect%]" diff --git a/requirements_all.txt b/requirements_all.txt index b6418c7be25078..8882cdd180fa26 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2786,9 +2786,6 @@ qingping-ble==1.1.0 # homeassistant.components.qnap qnapstats==0.4.0 -# homeassistant.components.threema -qrcode==8.2 - # homeassistant.components.quantum_gateway quantum-gateway==0.0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d0a704ece4be73..5849b2ca55da7d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2364,9 +2364,6 @@ qingping-ble==1.1.0 # homeassistant.components.qnap qnapstats==0.4.0 -# homeassistant.components.threema -qrcode==8.2 - # homeassistant.components.quantum_gateway quantum-gateway==0.0.8 diff --git a/tests/components/threema/test_image.py b/tests/components/threema/test_image.py deleted file mode 100644 index a07a24629bacdb..00000000000000 --- a/tests/components/threema/test_image.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Test the Threema Gateway image platform.""" - -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -from homeassistant.const import STATE_UNAVAILABLE -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er - -from tests.common import MockConfigEntry - - -async def test_qr_code_entity_created( - hass: HomeAssistant, - mock_config_entry_with_keys: MockConfigEntry, - mock_connection: MagicMock, - mock_send: MagicMock, -) -> None: - """Test QR code entity is created when public key is present.""" - mock_config_entry_with_keys.add_to_hass(hass) - - with patch("homeassistant.components.threema.image.qrcode.QRCode") as mock_qr_class: - mock_qr = MagicMock() - mock_qr_class.return_value = mock_qr - mock_img = MagicMock() - mock_qr.make_image.return_value = mock_img - mock_img.save = MagicMock( - side_effect=lambda buf, **kwargs: buf.write(b"fake_png_data") - ) - - await hass.config_entries.async_setup(mock_config_entry_with_keys.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - entities = er.async_entries_for_config_entry( - entity_registry, mock_config_entry_with_keys.entry_id - ) - image_entities = [e for e in entities if e.domain == "image"] - assert len(image_entities) == 1 - assert image_entities[0].unique_id == "*TESTGWY_qr_code" - - -async def test_qr_code_entity_not_created_without_key( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_connection: MagicMock, - mock_send: MagicMock, -) -> None: - """Test QR code entity is NOT created when no public key.""" - mock_config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - entities = er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) - image_entities = [e for e in entities if e.domain == "image"] - assert len(image_entities) == 0 - - -async def test_qr_code_image_available( - hass: HomeAssistant, - mock_config_entry_with_keys: MockConfigEntry, - mock_connection: MagicMock, - mock_send: MagicMock, -) -> None: - """Test QR code entity is available when image is generated.""" - mock_config_entry_with_keys.add_to_hass(hass) - - with patch("homeassistant.components.threema.image.qrcode.QRCode") as mock_qr_class: - mock_qr = MagicMock() - mock_qr_class.return_value = mock_qr - mock_img = MagicMock() - mock_qr.make_image.return_value = mock_img - mock_img.save = MagicMock( - side_effect=lambda buf, **kwargs: buf.write(b"fake_png_data") - ) - - await hass.config_entries.async_setup(mock_config_entry_with_keys.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - entities = er.async_entries_for_config_entry( - entity_registry, mock_config_entry_with_keys.entry_id - ) - image_entities = [e for e in entities if e.domain == "image"] - assert len(image_entities) == 1 - - state = hass.states.get(image_entities[0].entity_id) - assert state is not None - assert state.state != STATE_UNAVAILABLE From 8ec8b99879bfaaf5afa2106ae86ac493113ae714 Mon Sep 17 00:00:00 2001 From: LukasQ Date: Thu, 19 Mar 2026 14:36:53 +0100 Subject: [PATCH 10/40] Updated config flow and ID > friendly name --- homeassistant/components/threema/README.md | 7 +++++-- homeassistant/components/threema/__init__.py | 9 +++++++++ homeassistant/components/threema/config_flow.py | 8 +++++++- homeassistant/components/threema/notify.py | 1 - homeassistant/components/threema/strings.json | 2 ++ 5 files changed, 23 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/threema/README.md b/homeassistant/components/threema/README.md index 17705ee0e852d1..ff3950904b94c7 100644 --- a/homeassistant/components/threema/README.md +++ b/homeassistant/components/threema/README.md @@ -29,9 +29,12 @@ Silver. See `quality_scale.yaml` for full status. ## Roadmap +### Path to Gold (quality scale blockers) +- **Diagnostics platform** — expose gateway info, credits, configured recipients +- **Reconfiguration flow** — edit recipient name/Threema ID after creation (subentry reconfigure step). Currently: delete and re-add. + +### Future features - QR code image entity for gateway identity verification (follow-up PR, branch `feature/threema-qr`) - Incoming messages via Gateway callback webhooks - Image/file support (requires new platform or service — `NotifyEntity` only supports text + title) - Remaining credits sensor -- Diagnostics platform -- Reconfiguration flow diff --git a/homeassistant/components/threema/__init__.py b/homeassistant/components/threema/__init__.py index 354d503eeafafa..1a64fed95829e0 100644 --- a/homeassistant/components/threema/__init__.py +++ b/homeassistant/components/threema/__init__.py @@ -44,9 +44,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ThreemaConfigEntry) -> b entry.runtime_data = client await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) return True +async def _async_update_listener( + hass: HomeAssistant, entry: ThreemaConfigEntry +) -> None: + """Reload platforms when config entry is updated (e.g. subentry added/removed).""" + await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + async def async_unload_entry(hass: HomeAssistant, entry: ThreemaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/threema/config_flow.py b/homeassistant/components/threema/config_flow.py index 4157874353549e..966cd5938abc65 100644 --- a/homeassistant/components/threema/config_flow.py +++ b/homeassistant/components/threema/config_flow.py @@ -259,8 +259,13 @@ async def async_step_user( if subentry.data.get(CONF_RECIPIENT) == recipient_id: return self.async_abort(reason="already_configured") + raw_name = user_input.get("name", "").strip() + name = ( + f"{raw_name} ({recipient_id})" if raw_name else recipient_id + ) + return self.async_create_entry( - title=recipient_id, + title=name, data={CONF_RECIPIENT: recipient_id}, unique_id=recipient_id, ) @@ -270,6 +275,7 @@ async def async_step_user( data_schema=vol.Schema( { vol.Required(CONF_RECIPIENT): str, + vol.Optional("name"): str, } ), errors=errors, diff --git a/homeassistant/components/threema/notify.py b/homeassistant/components/threema/notify.py index 2631707b3aa515..df624c41345792 100644 --- a/homeassistant/components/threema/notify.py +++ b/homeassistant/components/threema/notify.py @@ -33,7 +33,6 @@ async def async_setup_entry( class ThreemaNotifyEntity(NotifyEntity): """Notify entity for sending messages to a Threema recipient.""" - _attr_has_entity_name = True _attr_supported_features = NotifyEntityFeature.TITLE def __init__( diff --git a/homeassistant/components/threema/strings.json b/homeassistant/components/threema/strings.json index 23cfb3bdf3ebac..e155c93f83e865 100644 --- a/homeassistant/components/threema/strings.json +++ b/homeassistant/components/threema/strings.json @@ -79,9 +79,11 @@ "step": { "user": { "data": { + "name": "Display name", "recipient": "Threema ID" }, "data_description": { + "name": "Optional friendly name for this recipient (e.g. 'Dad').", "recipient": "The 8-character Threema ID of the recipient." }, "description": "Enter the Threema ID of the person you want to send messages to.", From 8d430a85a3fa71cef4bb71d1c0ca1e33b58397cf Mon Sep 17 00:00:00 2001 From: LukasQ Date: Thu, 19 Mar 2026 20:47:51 +0100 Subject: [PATCH 11/40] fixed minor typo and Copilots recommendations. --- homeassistant/components/threema/__init__.py | 5 ++--- homeassistant/components/threema/config_flow.py | 6 +++--- homeassistant/components/threema/strings.json | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/threema/__init__.py b/homeassistant/components/threema/__init__.py index 1a64fed95829e0..942df97277f7d3 100644 --- a/homeassistant/components/threema/__init__.py +++ b/homeassistant/components/threema/__init__.py @@ -51,9 +51,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ThreemaConfigEntry) -> b async def _async_update_listener( hass: HomeAssistant, entry: ThreemaConfigEntry ) -> None: - """Reload platforms when config entry is updated (e.g. subentry added/removed).""" - await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + """Reload entry when config is updated (e.g. subentry added/removed).""" + await hass.config_entries.async_reload(entry.entry_id) async def async_unload_entry(hass: HomeAssistant, entry: ThreemaConfigEntry) -> bool: diff --git a/homeassistant/components/threema/config_flow.py b/homeassistant/components/threema/config_flow.py index 966cd5938abc65..c72a405d663cb9 100644 --- a/homeassistant/components/threema/config_flow.py +++ b/homeassistant/components/threema/config_flow.py @@ -15,6 +15,7 @@ ConfigSubentryFlow, SubentryFlowResult, ) +from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from .client import ( @@ -43,6 +44,7 @@ class ThreemaConfigFlow(ConfigFlow, domain=DOMAIN): MINOR_VERSION = 2 @classmethod + @callback def async_get_supported_subentry_types( cls, config_entry: ConfigEntry ) -> dict[str, type[ConfigSubentryFlow]]: @@ -260,9 +262,7 @@ async def async_step_user( return self.async_abort(reason="already_configured") raw_name = user_input.get("name", "").strip() - name = ( - f"{raw_name} ({recipient_id})" if raw_name else recipient_id - ) + name = f"{raw_name} ({recipient_id})" if raw_name else recipient_id return self.async_create_entry( title=name, diff --git a/homeassistant/components/threema/strings.json b/homeassistant/components/threema/strings.json index e155c93f83e865..42142c0338606e 100644 --- a/homeassistant/components/threema/strings.json +++ b/homeassistant/components/threema/strings.json @@ -59,7 +59,7 @@ "data_description": { "setup_type": "Choose 'existing' to enter your Gateway credentials, or 'new' to generate encryption keys first." }, - "description": "Choose whether to create a new Gateway ID or configure an existing one.", + "description": "Choose whether to generate encryption keys for a new Gateway ID (to be registered at gateway.threema.ch) or configure an existing Gateway ID.", "title": "Threema setup" } } From 19d94c28c41b2272596dee4f164fdeae739b1439 Mon Sep 17 00:00:00 2001 From: LukasQ Date: Mon, 23 Mar 2026 09:28:05 +0100 Subject: [PATCH 12/40] normalized inputs --- .../components/threema/config_flow.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/threema/config_flow.py b/homeassistant/components/threema/config_flow.py index c72a405d663cb9..63949bfb7f6154 100644 --- a/homeassistant/components/threema/config_flow.py +++ b/homeassistant/components/threema/config_flow.py @@ -123,12 +123,14 @@ async def async_step_credentials( self._abort_if_unique_id_configured() self._gateway_id = gateway_id - self._api_secret = user_input[CONF_API_SECRET] + self._api_secret = user_input[CONF_API_SECRET].strip() - if user_input.get(CONF_PRIVATE_KEY): - self._private_key = user_input[CONF_PRIVATE_KEY] - if user_input.get(CONF_PUBLIC_KEY): - self._public_key = user_input[CONF_PUBLIC_KEY] + private_key = user_input.get(CONF_PRIVATE_KEY, "").strip() or None + if private_key: + self._private_key = private_key + public_key = user_input.get(CONF_PUBLIC_KEY, "").strip() or None + if public_key: + self._public_key = public_key client = ThreemaAPIClient( self.hass, @@ -190,12 +192,13 @@ async def async_step_reauth_confirm( if user_input is not None: reauth_entry = self._get_reauth_entry() - private_key_input = user_input.get(CONF_PRIVATE_KEY) or None + api_secret = user_input[CONF_API_SECRET].strip() + private_key_input = user_input.get(CONF_PRIVATE_KEY, "").strip() or None client = ThreemaAPIClient( self.hass, gateway_id=reauth_entry.data[CONF_GATEWAY_ID], - api_secret=user_input[CONF_API_SECRET], + api_secret=api_secret, private_key=private_key_input or reauth_entry.data.get(CONF_PRIVATE_KEY), ) @@ -213,7 +216,7 @@ async def async_step_reauth_confirm( await self.async_set_unique_id(reauth_entry.data[CONF_GATEWAY_ID]) self._abort_if_unique_id_mismatch() data_updates: dict[str, str] = { - CONF_API_SECRET: user_input[CONF_API_SECRET], + CONF_API_SECRET: api_secret, } if private_key_input: data_updates[CONF_PRIVATE_KEY] = private_key_input From 202112a4d7141d0beb6155485e3b3b768c6a18b9 Mon Sep 17 00:00:00 2001 From: LukasQ Date: Mon, 23 Mar 2026 09:32:49 +0100 Subject: [PATCH 13/40] Exception handling improved --- homeassistant/components/threema/client.py | 63 +++++++++++----------- 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/threema/client.py b/homeassistant/components/threema/client.py index bf11640f8a1ccb..ba0306a67f9302 100644 --- a/homeassistant/components/threema/client.py +++ b/homeassistant/components/threema/client.py @@ -91,40 +91,39 @@ async def send_text_message(self, recipient_id: str, text: str) -> str: Returns the message ID on success. Raises ThreemaSendError on failure. """ - try: - async with self._get_connection() as conn: - if self.private_key: - _LOGGER.debug("Sending E2E encrypted message to %s", recipient_id) - message = TextMessage( - connection=conn, - to_id=recipient_id, - text=text, - ) - else: - _LOGGER.debug("Sending simple message to %s", recipient_id) - message = SimpleTextMessage( - connection=conn, - to_id=recipient_id, - text=text, - ) + async with self._get_connection() as conn: + if self.private_key: + _LOGGER.debug("Sending E2E encrypted message to %s", recipient_id) + message = TextMessage( + connection=conn, + to_id=recipient_id, + text=text, + ) + else: + _LOGGER.debug("Sending simple message to %s", recipient_id) + message = SimpleTextMessage( + connection=conn, + to_id=recipient_id, + text=text, + ) + try: message_id: str = await message.send() - _LOGGER.debug("Message sent to %s (ID: %s)", recipient_id, message_id) - return message_id - except GatewayServerError as err: - if err.status == _HTTP_UNAUTHORIZED: - raise ThreemaAuthError("Invalid Threema Gateway credentials") from err - raise ThreemaSendError( - f"Gateway server error sending message to {recipient_id}: {err}" - ) from err - except GatewayError as err: - raise ThreemaSendError( - f"Gateway error sending message to {recipient_id}: {err}" - ) from err - except Exception as err: - raise ThreemaSendError( - f"Failed to send message to {recipient_id}: {err}" - ) from err + except GatewayServerError as err: + if err.status == _HTTP_UNAUTHORIZED: + raise ThreemaAuthError( + "Invalid Threema Gateway credentials" + ) from err + raise ThreemaSendError( + f"Gateway server error sending message to {recipient_id}: {err}" + ) from err + except GatewayError as err: + raise ThreemaSendError( + f"Gateway error sending message to {recipient_id}: {err}" + ) from err + + _LOGGER.debug("Message sent to %s (ID: %s)", recipient_id, message_id) + return message_id def generate_key_pair() -> tuple[str, str]: From 4c2d9255492a08c1b53e63c5b682c27700f552ce Mon Sep 17 00:00:00 2001 From: LukasQ Date: Mon, 23 Mar 2026 09:34:55 +0100 Subject: [PATCH 14/40] Improved exception handling further --- homeassistant/components/threema/client.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/threema/client.py b/homeassistant/components/threema/client.py index ba0306a67f9302..2b758dbb98a057 100644 --- a/homeassistant/components/threema/client.py +++ b/homeassistant/components/threema/client.py @@ -80,10 +80,6 @@ async def validate_credentials(self) -> None: raise ThreemaConnectionError( f"Gateway error validating credentials: {err}" ) from err - except Exception as err: - raise ThreemaConnectionError( - f"Failed to validate credentials: {err}" - ) from err async def send_text_message(self, recipient_id: str, text: str) -> str: """Send a text message to a Threema ID. From db2e17a3ca542c2625cdba1d2a918eec14009c93 Mon Sep 17 00:00:00 2001 From: LukasQ Date: Mon, 23 Mar 2026 09:37:11 +0100 Subject: [PATCH 15/40] fixed versioning --- homeassistant/components/threema/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/threema/config_flow.py b/homeassistant/components/threema/config_flow.py index 63949bfb7f6154..9297bb164ddeb7 100644 --- a/homeassistant/components/threema/config_flow.py +++ b/homeassistant/components/threema/config_flow.py @@ -41,7 +41,7 @@ class ThreemaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Threema Gateway.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 1 @classmethod @callback From 0fda17d4affd2cff42a01ad40e029452950f8c1c Mon Sep 17 00:00:00 2001 From: LukasQ Date: Mon, 23 Mar 2026 09:49:49 +0100 Subject: [PATCH 16/40] Added full quality scale coverage --- homeassistant/components/threema/quality_scale.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/threema/quality_scale.yaml b/homeassistant/components/threema/quality_scale.yaml index e941badeee2450..2a1711bfea147d 100644 --- a/homeassistant/components/threema/quality_scale.yaml +++ b/homeassistant/components/threema/quality_scale.yaml @@ -87,3 +87,8 @@ rules: stale-devices: status: exempt comment: No devices in current scope. + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo From 240191fae0fa71136477a8aaa896d122a4c71852 Mon Sep 17 00:00:00 2001 From: LukasQ Date: Mon, 23 Mar 2026 09:58:33 +0100 Subject: [PATCH 17/40] Aded back entity name --- homeassistant/components/threema/notify.py | 8 ++++++++ homeassistant/components/threema/quality_scale.yaml | 4 +--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/threema/notify.py b/homeassistant/components/threema/notify.py index df624c41345792..90e954895625f6 100644 --- a/homeassistant/components/threema/notify.py +++ b/homeassistant/components/threema/notify.py @@ -8,6 +8,7 @@ from homeassistant.config_entries import ConfigSubentry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ThreemaConfigEntry @@ -33,6 +34,7 @@ async def async_setup_entry( class ThreemaNotifyEntity(NotifyEntity): """Notify entity for sending messages to a Threema recipient.""" + _attr_has_entity_name = True _attr_supported_features = NotifyEntityFeature.TITLE def __init__( @@ -48,6 +50,12 @@ def __init__( self._attr_unique_id = f"{gateway_id}_{subentry_id}" self._attr_name = subentry.title + self._attr_device_info = DeviceInfo( + name=entry.title, + entry_type=DeviceEntryType.SERVICE, + manufacturer="Threema", + identifiers={(DOMAIN, gateway_id)}, + ) async def async_send_message(self, message: str, title: str | None = None) -> None: """Send a message to the configured Threema recipient.""" diff --git a/homeassistant/components/threema/quality_scale.yaml b/homeassistant/components/threema/quality_scale.yaml index 2a1711bfea147d..046c8ff571b4df 100644 --- a/homeassistant/components/threema/quality_scale.yaml +++ b/homeassistant/components/threema/quality_scale.yaml @@ -42,9 +42,7 @@ rules: test-coverage: done # Gold - devices: - status: exempt - comment: Notify entities do not require a device. Gateway device will be added with QR code image platform in a follow-up. + devices: done diagnostics: todo discovery: status: exempt From daa9590d6f3d563584da06daf5fa2bb5d78be603 Mon Sep 17 00:00:00 2001 From: LukasQ Date: Mon, 23 Mar 2026 10:04:38 +0100 Subject: [PATCH 18/40] Fixed unneeded logging --- homeassistant/components/threema/notify.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/homeassistant/components/threema/notify.py b/homeassistant/components/threema/notify.py index 90e954895625f6..0d27a8daa435ea 100644 --- a/homeassistant/components/threema/notify.py +++ b/homeassistant/components/threema/notify.py @@ -2,8 +2,6 @@ from __future__ import annotations -import logging - from homeassistant.components.notify import NotifyEntity, NotifyEntityFeature from homeassistant.config_entries import ConfigSubentry from homeassistant.core import HomeAssistant @@ -15,8 +13,6 @@ from .client import ThreemaAuthError, ThreemaConnectionError, ThreemaSendError from .const import CONF_GATEWAY_ID, CONF_RECIPIENT, DOMAIN -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistant, @@ -64,9 +60,6 @@ async def async_send_message(self, message: str, title: str | None = None) -> No try: await client.send_text_message(self._recipient_id, text) except ThreemaAuthError as err: - _LOGGER.warning( - "Authentication failed sending message; check your Gateway credentials" - ) raise HomeAssistantError( translation_domain=DOMAIN, translation_key="send_error", From a5b325be08206f6f5408f83347f80d11f6827f5a Mon Sep 17 00:00:00 2001 From: LukasQ Date: Mon, 23 Mar 2026 11:02:40 +0100 Subject: [PATCH 19/40] Fixed tests --- tests/components/threema/test_init.py | 6 +++++- tests/components/threema/test_notify.py | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/components/threema/test_init.py b/tests/components/threema/test_init.py index cb7283ca4b0dfb..2bb74ec8307d5a 100644 --- a/tests/components/threema/test_init.py +++ b/tests/components/threema/test_init.py @@ -4,6 +4,8 @@ from unittest.mock import AsyncMock, MagicMock, patch +from threema.gateway import GatewayError + from homeassistant.components.threema.client import ThreemaAuthError from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -39,7 +41,9 @@ async def test_setup_entry_connection_error( connection = MagicMock() connection.__aenter__ = AsyncMock(return_value=connection) connection.__aexit__ = AsyncMock(return_value=None) - connection.get_credits = AsyncMock(side_effect=Exception("Connection refused")) + connection.get_credits = AsyncMock( + side_effect=GatewayError("Connection refused") + ) connection_class.return_value = connection await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/components/threema/test_notify.py b/tests/components/threema/test_notify.py index d6e3f51be7d0eb..64863924b5222b 100644 --- a/tests/components/threema/test_notify.py +++ b/tests/components/threema/test_notify.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from threema.gateway import GatewayError from threema.gateway.exception import GatewayServerError from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN @@ -86,6 +87,7 @@ async def test_send_message_simple( ) mock_send.simple.assert_called_once() + mock_send.simple.return_value.send.assert_awaited_once() async def test_send_message_e2e( @@ -127,6 +129,7 @@ async def test_send_message_e2e( ) mock_send.e2e.assert_called_once() + mock_send.e2e.return_value.send.assert_awaited_once() async def test_send_message_auth_error( @@ -177,7 +180,7 @@ async def test_send_message_send_error( "homeassistant.components.threema.client.SimpleTextMessage", autospec=True ) as simple_mock: simple_instance = MagicMock() - simple_instance.send = AsyncMock(side_effect=Exception("Network error")) + simple_instance.send = AsyncMock(side_effect=GatewayError("Network error")) simple_mock.return_value = simple_instance await hass.config_entries.async_setup(mock_config_entry_with_subentry.entry_id) From 20bc0c33eec726d4455e9532393d35d938c2d0a4 Mon Sep 17 00:00:00 2001 From: LukasQ Date: Mon, 23 Mar 2026 11:06:44 +0100 Subject: [PATCH 20/40] Fixed whitespace. --- homeassistant/components/threema/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/threema/config_flow.py b/homeassistant/components/threema/config_flow.py index 9297bb164ddeb7..aaccd424f0b804 100644 --- a/homeassistant/components/threema/config_flow.py +++ b/homeassistant/components/threema/config_flow.py @@ -135,7 +135,7 @@ async def async_step_credentials( client = ThreemaAPIClient( self.hass, gateway_id=gateway_id, - api_secret=user_input[CONF_API_SECRET], + api_secret=self._api_secret, private_key=self._private_key, ) From d74174575cd66a339927ae924295cbe324ff9246 Mon Sep 17 00:00:00 2001 From: LukasQ Date: Mon, 23 Mar 2026 11:10:36 +0100 Subject: [PATCH 21/40] ruff-ed --- homeassistant/components/threema/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/threema/config_flow.py b/homeassistant/components/threema/config_flow.py index aaccd424f0b804..9423a3ef5f7793 100644 --- a/homeassistant/components/threema/config_flow.py +++ b/homeassistant/components/threema/config_flow.py @@ -179,7 +179,7 @@ async def async_step_credentials( ) async def async_step_reauth( - self, entry_data: Mapping[str, Any] + self, _: Mapping[str, Any] ) -> ConfigFlowResult: """Handle reauth if credentials become invalid.""" return await self.async_step_reauth_confirm() From af6fa62a5d8f72ef1d993f792217b67b297c025d Mon Sep 17 00:00:00 2001 From: LukasQ Date: Mon, 23 Mar 2026 11:20:48 +0100 Subject: [PATCH 22/40] ruff-ed 2.0 --- homeassistant/components/threema/config_flow.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/threema/config_flow.py b/homeassistant/components/threema/config_flow.py index 9423a3ef5f7793..96c89009be314b 100644 --- a/homeassistant/components/threema/config_flow.py +++ b/homeassistant/components/threema/config_flow.py @@ -178,9 +178,7 @@ async def async_step_credentials( errors=errors, ) - async def async_step_reauth( - self, _: Mapping[str, Any] - ) -> ConfigFlowResult: + async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult: """Handle reauth if credentials become invalid.""" return await self.async_step_reauth_confirm() From bf15793ae57a80499e827a21a6575adc83276f4c Mon Sep 17 00:00:00 2001 From: LukasQ Date: Mon, 23 Mar 2026 11:43:12 +0100 Subject: [PATCH 23/40] Propper exception catch --- tests/components/threema/test_config_flow.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/components/threema/test_config_flow.py b/tests/components/threema/test_config_flow.py index 4c7ed60b7f4b79..4c459ae874f2ad 100644 --- a/tests/components/threema/test_config_flow.py +++ b/tests/components/threema/test_config_flow.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from threema.gateway import GatewayError from homeassistant import config_entries from homeassistant.components.threema.client import ThreemaAuthError @@ -235,7 +236,9 @@ async def test_credentials_cannot_connect(hass: HomeAssistant) -> None: connection = MagicMock() connection.__aenter__ = AsyncMock(return_value=connection) connection.__aexit__ = AsyncMock(return_value=None) - connection.get_credits = AsyncMock(side_effect=Exception("Connection refused")) + connection.get_credits = AsyncMock( + side_effect=GatewayError("Connection refused") + ) connection_class.return_value = connection result = await hass.config_entries.flow.async_init( @@ -367,7 +370,9 @@ async def test_reauth_flow_cannot_connect( connection = MagicMock() connection.__aenter__ = AsyncMock(return_value=connection) connection.__aexit__ = AsyncMock(return_value=None) - connection.get_credits = AsyncMock(side_effect=Exception("Connection refused")) + connection.get_credits = AsyncMock( + side_effect=GatewayError("Connection refused") + ) connection_class.return_value = connection result = await mock_config_entry.start_reauth_flow(hass) From ca763330f7b4568b6d2dbea653512ff069035d3e Mon Sep 17 00:00:00 2001 From: LukasQ Date: Thu, 26 Mar 2026 14:28:52 +0100 Subject: [PATCH 24/40] Improved UI --- homeassistant/components/threema/config_flow.py | 6 +++--- homeassistant/components/threema/strings.json | 2 +- tests/components/threema/test_config_flow.py | 16 ++++++++-------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/threema/config_flow.py b/homeassistant/components/threema/config_flow.py index 96c89009be314b..1b4bddcb462d24 100644 --- a/homeassistant/components/threema/config_flow.py +++ b/homeassistant/components/threema/config_flow.py @@ -61,7 +61,7 @@ async def async_step_user( ) -> ConfigFlowResult: """Handle the initial step - choose setup type.""" if user_input is not None: - if user_input.get("setup_type") == "new": + if user_input.get("setup_type") == "generate_keys": return await self.async_step_setup_new() return await self.async_step_credentials() @@ -69,8 +69,8 @@ async def async_step_user( step_id="user", data_schema=vol.Schema( { - vol.Required("setup_type", default="existing"): vol.In( - ["existing", "new"] + vol.Required("setup_type", default="add_gateway"): vol.In( + ["add_gateway", "generate_keys"] ), } ), diff --git a/homeassistant/components/threema/strings.json b/homeassistant/components/threema/strings.json index 42142c0338606e..cb33c1d9fed101 100644 --- a/homeassistant/components/threema/strings.json +++ b/homeassistant/components/threema/strings.json @@ -57,7 +57,7 @@ "setup_type": "Setup type" }, "data_description": { - "setup_type": "Choose 'existing' to enter your Gateway credentials, or 'new' to generate encryption keys first." + "setup_type": "Choose 'add_gateway' to enter your Gateway credentials, or 'generate_keys' to create a new key pair first." }, "description": "Choose whether to generate encryption keys for a new Gateway ID (to be registered at gateway.threema.ch) or configure an existing Gateway ID.", "title": "Threema setup" diff --git a/tests/components/threema/test_config_flow.py b/tests/components/threema/test_config_flow.py index 4c459ae874f2ad..a59474e6c667cf 100644 --- a/tests/components/threema/test_config_flow.py +++ b/tests/components/threema/test_config_flow.py @@ -46,7 +46,7 @@ async def test_user_flow_existing_gateway( # Choose existing gateway result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"setup_type": "existing"}, + user_input={"setup_type": "add_gateway"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "credentials" @@ -79,7 +79,7 @@ async def test_user_flow_existing_with_keys( result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"setup_type": "existing"}, + user_input={"setup_type": "add_gateway"}, ) result = await hass.config_entries.flow.async_configure( @@ -116,7 +116,7 @@ async def test_user_flow_new_gateway( # Choose new gateway result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"setup_type": "new"}, + user_input={"setup_type": "generate_keys"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "setup_new" @@ -159,7 +159,7 @@ async def test_user_flow_key_generation_failure(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"setup_type": "new"}, + user_input={"setup_type": "generate_keys"}, ) assert result["type"] is FlowResultType.ABORT @@ -175,7 +175,7 @@ async def test_credentials_invalid_gateway_id( ) result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"setup_type": "existing"}, + user_input={"setup_type": "add_gateway"}, ) # Gateway ID not starting with * @@ -214,7 +214,7 @@ async def test_credentials_already_configured( ) result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"setup_type": "existing"}, + user_input={"setup_type": "add_gateway"}, ) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -246,7 +246,7 @@ async def test_credentials_cannot_connect(hass: HomeAssistant) -> None: ) result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"setup_type": "existing"}, + user_input={"setup_type": "add_gateway"}, ) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -271,7 +271,7 @@ async def test_credentials_invalid_auth(hass: HomeAssistant) -> None: ) result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"setup_type": "existing"}, + user_input={"setup_type": "add_gateway"}, ) result = await hass.config_entries.flow.async_configure( result["flow_id"], From 9017161c28eae3e20d6c558d7f9955dcd435d915 Mon Sep 17 00:00:00 2001 From: LukasQ Date: Thu, 26 Mar 2026 14:30:34 +0100 Subject: [PATCH 25/40] improved subentry --- homeassistant/components/threema/notify.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/threema/notify.py b/homeassistant/components/threema/notify.py index 0d27a8daa435ea..a7860152ce50af 100644 --- a/homeassistant/components/threema/notify.py +++ b/homeassistant/components/threema/notify.py @@ -22,7 +22,7 @@ async def async_setup_entry( """Set up Threema notify entities from config entry subentries.""" for subentry_id, subentry in entry.subentries.items(): async_add_entities( - [ThreemaNotifyEntity(entry, subentry_id, subentry)], + [ThreemaNotifyEntity(entry, subentry)], config_subentry_id=subentry_id, ) @@ -36,7 +36,6 @@ class ThreemaNotifyEntity(NotifyEntity): def __init__( self, entry: ThreemaConfigEntry, - subentry_id: str, subentry: ConfigSubentry, ) -> None: """Initialize the notify entity.""" @@ -44,7 +43,7 @@ def __init__( self._recipient_id: str = subentry.data[CONF_RECIPIENT] gateway_id = entry.data[CONF_GATEWAY_ID] - self._attr_unique_id = f"{gateway_id}_{subentry_id}" + self._attr_unique_id = f"{gateway_id}_{subentry.subentry_id}" self._attr_name = subentry.title self._attr_device_info = DeviceInfo( name=entry.title, From 9232502aafeffb489e571f80461349b852ebec02 Mon Sep 17 00:00:00 2001 From: LukasQ Date: Thu, 26 Mar 2026 14:32:25 +0100 Subject: [PATCH 26/40] Changed UID --- homeassistant/components/threema/notify.py | 2 +- tests/components/threema/test_notify.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/threema/notify.py b/homeassistant/components/threema/notify.py index a7860152ce50af..5d62e592297d67 100644 --- a/homeassistant/components/threema/notify.py +++ b/homeassistant/components/threema/notify.py @@ -43,7 +43,7 @@ def __init__( self._recipient_id: str = subentry.data[CONF_RECIPIENT] gateway_id = entry.data[CONF_GATEWAY_ID] - self._attr_unique_id = f"{gateway_id}_{subentry.subentry_id}" + self._attr_unique_id = f"{gateway_id}_{self._recipient_id}" self._attr_name = subentry.title self._attr_device_info = DeviceInfo( name=entry.title, diff --git a/tests/components/threema/test_notify.py b/tests/components/threema/test_notify.py index 64863924b5222b..e305c7dca8d4b3 100644 --- a/tests/components/threema/test_notify.py +++ b/tests/components/threema/test_notify.py @@ -39,7 +39,7 @@ async def test_notify_entity_created( ) notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] assert len(notify_entities) == 1 - assert notify_entities[0].unique_id == f"{MOCK_GATEWAY_ID}_{MOCK_SUBENTRY_ID}" + assert notify_entities[0].unique_id == f"{MOCK_GATEWAY_ID}_{MOCK_RECIPIENT_ID}" async def test_notify_entity_not_created_without_subentry( From b70fe865a6920470cd128f607d4b3463ce36a00b Mon Sep 17 00:00:00 2001 From: LukasQ Date: Mon, 30 Mar 2026 10:26:08 +0200 Subject: [PATCH 27/40] Added new tests for codecov --- tests/components/threema/test_config_flow.py | 31 ++++++++++++++ tests/components/threema/test_init.py | 45 ++++++++++++++++++++ tests/components/threema/test_notify.py | 36 ++++++++++++++++ 3 files changed, 112 insertions(+) diff --git a/tests/components/threema/test_config_flow.py b/tests/components/threema/test_config_flow.py index a59474e6c667cf..18d2c7ac7b2547 100644 --- a/tests/components/threema/test_config_flow.py +++ b/tests/components/threema/test_config_flow.py @@ -6,6 +6,7 @@ import pytest from threema.gateway import GatewayError +from threema.gateway.exception import GatewayServerError from homeassistant import config_entries from homeassistant.components.threema.client import ThreemaAuthError @@ -260,6 +261,36 @@ async def test_credentials_cannot_connect(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} +async def test_credentials_server_error_non_auth(hass: HomeAssistant) -> None: + """Test credentials step with non-401 server error.""" + with patch( + "homeassistant.components.threema.client.Connection", autospec=True + ) as connection_class: + connection = MagicMock() + connection.__aenter__ = AsyncMock(return_value=connection) + connection.__aexit__ = AsyncMock(return_value=None) + connection.get_credits = AsyncMock(side_effect=GatewayServerError(status=500)) + connection_class.return_value = connection + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"setup_type": "add_gateway"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + async def test_credentials_invalid_auth(hass: HomeAssistant) -> None: """Test credentials step with invalid authentication.""" with patch( diff --git a/tests/components/threema/test_init.py b/tests/components/threema/test_init.py index 2bb74ec8307d5a..18ce0ab7fba506 100644 --- a/tests/components/threema/test_init.py +++ b/tests/components/threema/test_init.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from threema.gateway import GatewayError +from threema.gateway.exception import GatewayServerError from homeassistant.components.threema.client import ThreemaAuthError from homeassistant.config_entries import ConfigEntryState @@ -85,3 +86,47 @@ async def test_unload_entry( await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_entry_server_error_non_auth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup retries on non-401 server error.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.threema.client.Connection", autospec=True + ) as connection_class: + connection = MagicMock() + connection.__aenter__ = AsyncMock(return_value=connection) + connection.__aexit__ = AsyncMock(return_value=None) + connection.get_credits = AsyncMock(side_effect=GatewayServerError(status=500)) + connection_class.return_value = connection + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_update_listener_reloads( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: MagicMock, +) -> None: + """Test that update listener reloads the entry.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + with patch( + "homeassistant.config_entries.ConfigEntries.async_reload" + ) as mock_reload: + mock_reload.return_value = None + hass.config_entries.async_update_entry(mock_config_entry, title="Updated") + await hass.async_block_till_done() + + mock_reload.assert_called_once_with(mock_config_entry.entry_id) diff --git a/tests/components/threema/test_notify.py b/tests/components/threema/test_notify.py index e305c7dca8d4b3..a42ae209cd0ed6 100644 --- a/tests/components/threema/test_notify.py +++ b/tests/components/threema/test_notify.py @@ -202,3 +202,39 @@ async def test_send_message_send_error( }, blocking=True, ) + + +async def test_send_message_server_error_non_auth( + hass: HomeAssistant, + mock_config_entry_with_subentry: MockConfigEntry, + mock_connection: MagicMock, +) -> None: + """Test notify entity handles non-401 server error.""" + mock_config_entry_with_subentry.add_to_hass(hass) + + with patch( + "homeassistant.components.threema.client.SimpleTextMessage", autospec=True + ) as simple_mock: + simple_instance = MagicMock() + simple_instance.send = AsyncMock(side_effect=GatewayServerError(status=500)) + simple_mock.return_value = simple_instance + + await hass.config_entries.async_setup(mock_config_entry_with_subentry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry_with_subentry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NOTIFY_DOMAIN, + "send_message", + { + "entity_id": notify_entities[0].entity_id, + "message": "Hello!", + }, + blocking=True, + ) From e683d7bad6e4baee40a52402002067c8faafa4f6 Mon Sep 17 00:00:00 2001 From: LukasQ Date: Mon, 30 Mar 2026 11:31:37 +0200 Subject: [PATCH 28/40] improved codecov --- tests/components/threema/test_config_flow.py | 70 ++++++++++++++++++++ tests/components/threema/test_init.py | 12 ++-- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/tests/components/threema/test_config_flow.py b/tests/components/threema/test_config_flow.py index 18d2c7ac7b2547..2df492a2626589 100644 --- a/tests/components/threema/test_config_flow.py +++ b/tests/components/threema/test_config_flow.py @@ -316,6 +316,31 @@ async def test_credentials_invalid_auth(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "invalid_auth"} +async def test_credentials_unknown_error(hass: HomeAssistant) -> None: + """Test credentials step with unexpected error.""" + with patch( + "homeassistant.components.threema.config_flow.ThreemaAPIClient.validate_credentials", + side_effect=RuntimeError("Unexpected"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"setup_type": "add_gateway"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + async def test_reauth_flow_invalid_auth( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -418,6 +443,51 @@ async def test_reauth_flow_cannot_connect( assert result["errors"] == {"base": "cannot_connect"} +async def test_reauth_flow_unknown_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with unexpected error.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.threema.config_flow.ThreemaAPIClient.validate_credentials", + side_effect=RuntimeError("Unexpected"), + ): + result = await mock_config_entry.start_reauth_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_SECRET: "new_secret", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_reauth_flow_updates_private_key( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, +) -> None: + """Test reauth flow updates private key when provided.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_SECRET: "new_secret", + CONF_PRIVATE_KEY: "private:newkey123", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_PRIVATE_KEY] == "private:newkey123" + + async def test_subentry_add_recipient( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/threema/test_init.py b/tests/components/threema/test_init.py index 18ce0ab7fba506..f61fb1f227f86e 100644 --- a/tests/components/threema/test_init.py +++ b/tests/components/threema/test_init.py @@ -7,7 +7,6 @@ from threema.gateway import GatewayError from threema.gateway.exception import GatewayServerError -from homeassistant.components.threema.client import ThreemaAuthError from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -61,9 +60,14 @@ async def test_setup_entry_auth_error( mock_config_entry.add_to_hass(hass) with patch( - "homeassistant.components.threema.ThreemaAPIClient.validate_credentials", - side_effect=ThreemaAuthError("Invalid credentials"), - ): + "homeassistant.components.threema.client.Connection", autospec=True + ) as connection_class: + connection = MagicMock() + connection.__aenter__ = AsyncMock(return_value=connection) + connection.__aexit__ = AsyncMock(return_value=None) + connection.get_credits = AsyncMock(side_effect=GatewayServerError(status=401)) + connection_class.return_value = connection + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() From 9b3aaa9608b74e346a8c5b44ae1dadd71fdfed64 Mon Sep 17 00:00:00 2001 From: LukasQ Date: Thu, 2 Apr 2026 09:25:49 +0200 Subject: [PATCH 29/40] One push to rule them all. --- .../components/threema/config_flow.py | 20 +- homeassistant/components/threema/notify.py | 26 +- homeassistant/components/threema/strings.json | 9 +- tests/components/threema/conftest.py | 48 +-- tests/components/threema/test_config_flow.py | 325 +++++++----------- tests/components/threema/test_init.py | 87 ++--- tests/components/threema/test_notify.py | 238 ++++++------- 7 files changed, 280 insertions(+), 473 deletions(-) diff --git a/homeassistant/components/threema/config_flow.py b/homeassistant/components/threema/config_flow.py index 1b4bddcb462d24..8425c2f50a0fea 100644 --- a/homeassistant/components/threema/config_flow.py +++ b/homeassistant/components/threema/config_flow.py @@ -15,6 +15,7 @@ ConfigSubentryFlow, SubentryFlowResult, ) +from homeassistant.const import CONF_NAME from homeassistant.core import callback from homeassistant.helpers import config_validation as cv @@ -60,20 +61,9 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step - choose setup type.""" - if user_input is not None: - if user_input.get("setup_type") == "generate_keys": - return await self.async_step_setup_new() - return await self.async_step_credentials() - - return self.async_show_form( + return self.async_show_menu( step_id="user", - data_schema=vol.Schema( - { - vol.Required("setup_type", default="add_gateway"): vol.In( - ["add_gateway", "generate_keys"] - ), - } - ), + menu_options=["credentials", "setup_new"], ) async def async_step_setup_new( @@ -262,7 +252,7 @@ async def async_step_user( if subentry.data.get(CONF_RECIPIENT) == recipient_id: return self.async_abort(reason="already_configured") - raw_name = user_input.get("name", "").strip() + raw_name = user_input.get(CONF_NAME, "").strip() name = f"{raw_name} ({recipient_id})" if raw_name else recipient_id return self.async_create_entry( @@ -276,7 +266,7 @@ async def async_step_user( data_schema=vol.Schema( { vol.Required(CONF_RECIPIENT): str, - vol.Optional("name"): str, + vol.Optional(CONF_NAME): str, } ), errors=errors, diff --git a/homeassistant/components/threema/notify.py b/homeassistant/components/threema/notify.py index 5d62e592297d67..bae6508238618e 100644 --- a/homeassistant/components/threema/notify.py +++ b/homeassistant/components/threema/notify.py @@ -10,8 +10,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ThreemaConfigEntry -from .client import ThreemaAuthError, ThreemaConnectionError, ThreemaSendError -from .const import CONF_GATEWAY_ID, CONF_RECIPIENT, DOMAIN +from .client import ( + ThreemaAPIClient, + ThreemaAuthError, + ThreemaConnectionError, + ThreemaSendError, +) +from .const import CONF_RECIPIENT, DOMAIN, SUBENTRY_TYPE_RECIPIENT async def async_setup_entry( @@ -21,8 +26,10 @@ async def async_setup_entry( ) -> None: """Set up Threema notify entities from config entry subentries.""" for subentry_id, subentry in entry.subentries.items(): + if subentry.subentry_type != SUBENTRY_TYPE_RECIPIENT: + continue async_add_entities( - [ThreemaNotifyEntity(entry, subentry)], + [ThreemaNotifyEntity(entry.runtime_data, subentry)], config_subentry_id=subentry_id, ) @@ -35,29 +42,26 @@ class ThreemaNotifyEntity(NotifyEntity): def __init__( self, - entry: ThreemaConfigEntry, + client: ThreemaAPIClient, subentry: ConfigSubentry, ) -> None: """Initialize the notify entity.""" - self._entry = entry + self._client = client self._recipient_id: str = subentry.data[CONF_RECIPIENT] - gateway_id = entry.data[CONF_GATEWAY_ID] - self._attr_unique_id = f"{gateway_id}_{self._recipient_id}" + self._attr_unique_id = f"{client.gateway_id}_{self._recipient_id}" self._attr_name = subentry.title self._attr_device_info = DeviceInfo( - name=entry.title, entry_type=DeviceEntryType.SERVICE, manufacturer="Threema", - identifiers={(DOMAIN, gateway_id)}, + identifiers={(DOMAIN, client.gateway_id)}, ) async def async_send_message(self, message: str, title: str | None = None) -> None: """Send a message to the configured Threema recipient.""" text = f"*{title}*\n{message}" if title else message - client = self._entry.runtime_data try: - await client.send_text_message(self._recipient_id, text) + await self._client.send_text_message(self._recipient_id, text) except ThreemaAuthError as err: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/threema/strings.json b/homeassistant/components/threema/strings.json index cb33c1d9fed101..d645323c96461a 100644 --- a/homeassistant/components/threema/strings.json +++ b/homeassistant/components/threema/strings.json @@ -53,13 +53,10 @@ "title": "Keys generated" }, "user": { - "data": { - "setup_type": "Setup type" - }, - "data_description": { - "setup_type": "Choose 'add_gateway' to enter your Gateway credentials, or 'generate_keys' to create a new key pair first." + "menu_options": { + "credentials": "Add existing Gateway ID", + "setup_new": "Generate new encryption keys" }, - "description": "Choose whether to generate encryption keys for a new Gateway ID (to be registered at gateway.threema.ch) or configure an existing Gateway ID.", "title": "Threema setup" } } diff --git a/tests/components/threema/conftest.py b/tests/components/threema/conftest.py index 03c4a0da8ddb7f..8f3ae576f625f1 100644 --- a/tests/components/threema/conftest.py +++ b/tests/components/threema/conftest.py @@ -12,10 +12,9 @@ CONF_GATEWAY_ID, CONF_PRIVATE_KEY, CONF_PUBLIC_KEY, - CONF_RECIPIENT, DOMAIN, - SUBENTRY_TYPE_RECIPIENT, ) +from homeassistant.config_entries import ConfigSubentryDataWithId from tests.common import MockConfigEntry @@ -32,55 +31,41 @@ @pytest.fixture -def mock_config_entry() -> MockConfigEntry: - """Return a mocked config entry.""" - return MockConfigEntry( - title=f"Threema {MOCK_GATEWAY_ID}", - domain=DOMAIN, - data={ - CONF_GATEWAY_ID: MOCK_GATEWAY_ID, - CONF_API_SECRET: MOCK_API_SECRET, - }, - unique_id=MOCK_GATEWAY_ID, - ) +def mock_subentries() -> list[ConfigSubentryDataWithId]: + """Fixture for subentries, override in tests that need recipients.""" + return [] @pytest.fixture -def mock_config_entry_with_keys() -> MockConfigEntry: - """Return a mocked config entry with encryption keys.""" +def mock_config_entry( + mock_subentries: list[ConfigSubentryDataWithId], +) -> MockConfigEntry: + """Return a mocked config entry for basic mode (no encryption).""" return MockConfigEntry( title=f"Threema {MOCK_GATEWAY_ID}", domain=DOMAIN, data={ CONF_GATEWAY_ID: MOCK_GATEWAY_ID, CONF_API_SECRET: MOCK_API_SECRET, - CONF_PRIVATE_KEY: MOCK_PRIVATE_KEY, - CONF_PUBLIC_KEY: MOCK_PUBLIC_KEY, }, unique_id=MOCK_GATEWAY_ID, + subentries_data=[*mock_subentries], ) @pytest.fixture -def mock_config_entry_with_subentry() -> MockConfigEntry: - """Return a mocked config entry with a recipient subentry.""" +def mock_config_entry_with_keys() -> MockConfigEntry: + """Return a mocked config entry for E2E encrypted mode.""" return MockConfigEntry( title=f"Threema {MOCK_GATEWAY_ID}", domain=DOMAIN, data={ CONF_GATEWAY_ID: MOCK_GATEWAY_ID, CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: MOCK_PRIVATE_KEY, + CONF_PUBLIC_KEY: MOCK_PUBLIC_KEY, }, unique_id=MOCK_GATEWAY_ID, - subentries_data=[ - { - "data": {CONF_RECIPIENT: MOCK_RECIPIENT_ID}, - "subentry_id": MOCK_SUBENTRY_ID, - "subentry_type": SUBENTRY_TYPE_RECIPIENT, - "title": MOCK_RECIPIENT_ID, - "unique_id": MOCK_RECIPIENT_ID, - }, - ], ) @@ -90,16 +75,15 @@ def mock_connection() -> Generator[MagicMock]: with patch( "homeassistant.components.threema.client.Connection", autospec=True ) as connection_class: - connection = MagicMock() + connection = connection_class.return_value connection.__aenter__ = AsyncMock(return_value=connection) connection.__aexit__ = AsyncMock(return_value=None) connection.get_credits = AsyncMock(return_value=100) - connection_class.return_value = connection yield connection @pytest.fixture -def mock_send() -> Generator[MagicMock]: +def mock_send() -> Generator[tuple[MagicMock, MagicMock]]: """Mock TextMessage and SimpleTextMessage send methods.""" with ( patch( @@ -117,4 +101,4 @@ def mock_send() -> Generator[MagicMock]: simple_instance.send = AsyncMock(return_value="mock_message_id") simple_mock.return_value = simple_instance - yield MagicMock(e2e=e2e_mock, simple=simple_mock) + yield (e2e_mock, simple_mock) diff --git a/tests/components/threema/test_config_flow.py b/tests/components/threema/test_config_flow.py index 2df492a2626589..95ff8912b4449d 100644 --- a/tests/components/threema/test_config_flow.py +++ b/tests/components/threema/test_config_flow.py @@ -2,14 +2,13 @@ from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch import pytest from threema.gateway import GatewayError from threema.gateway.exception import GatewayServerError from homeassistant import config_entries -from homeassistant.components.threema.client import ThreemaAuthError from homeassistant.components.threema.const import ( CONF_API_SECRET, CONF_GATEWAY_ID, @@ -41,18 +40,15 @@ async def test_user_flow_existing_gateway( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["type"] is FlowResultType.MENU - # Choose existing gateway result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"setup_type": "add_gateway"}, + {"next_step_id": "credentials"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "credentials" - # Enter credentials result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -68,6 +64,7 @@ async def test_user_flow_existing_gateway( CONF_GATEWAY_ID: MOCK_GATEWAY_ID, CONF_API_SECRET: MOCK_API_SECRET, } + assert result["result"].unique_id == MOCK_GATEWAY_ID async def test_user_flow_existing_with_keys( @@ -77,10 +74,11 @@ async def test_user_flow_existing_with_keys( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] is FlowResultType.MENU result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"setup_type": "add_gateway"}, + {"next_step_id": "credentials"}, ) result = await hass.config_entries.flow.async_configure( @@ -97,43 +95,51 @@ async def test_user_flow_existing_with_keys( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_PRIVATE_KEY] == "private:abcdef1234567890" assert result["data"][CONF_PUBLIC_KEY] == "public:1234567890abcdef" + assert result["result"].unique_id == MOCK_GATEWAY_ID async def test_user_flow_new_gateway( hass: HomeAssistant, mock_connection: MagicMock ) -> None: """Test user flow with new gateway (key generation).""" - mock_private = "private:generated_private_key_hex" - mock_public = "public:generated_public_key_hex" - - with patch( - "homeassistant.components.threema.config_flow.generate_key_pair", - return_value=(mock_private, mock_public), + mock_private_key = MagicMock() + mock_public_key = MagicMock() + + with ( + patch( + "homeassistant.components.threema.client.key.Key.generate_pair", + return_value=(mock_private_key, mock_public_key), + ), + patch( + "homeassistant.components.threema.client.key.Key.encode", + side_effect=[ + "private:generated_private_key_hex", + "public:generated_public_key_hex", + ], + ), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] is FlowResultType.MENU - # Choose new gateway result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"setup_type": "generate_keys"}, + {"next_step_id": "setup_new"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "setup_new" - # Confirm keys and proceed result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - "public_key": mock_public, - "private_key": mock_private, + "public_key": "public:generated_public_key_hex", + "private_key": "private:generated_private_key_hex", }, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "credentials" - # Enter gateway credentials result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -144,23 +150,25 @@ async def test_user_flow_new_gateway( await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_PRIVATE_KEY] == mock_private - assert result["data"][CONF_PUBLIC_KEY] == mock_public + assert result["data"][CONF_PRIVATE_KEY] == "private:generated_private_key_hex" + assert result["data"][CONF_PUBLIC_KEY] == "public:generated_public_key_hex" + assert result["result"].unique_id == MOCK_GATEWAY_ID async def test_user_flow_key_generation_failure(hass: HomeAssistant) -> None: """Test user flow aborts when key generation fails.""" with patch( - "homeassistant.components.threema.config_flow.generate_key_pair", + "homeassistant.components.threema.client.key.Key.generate_pair", side_effect=RuntimeError("Key generation failed"), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] is FlowResultType.MENU result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"setup_type": "generate_keys"}, + {"next_step_id": "setup_new"}, ) assert result["type"] is FlowResultType.ABORT @@ -174,9 +182,11 @@ async def test_credentials_invalid_gateway_id( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] is FlowResultType.MENU + result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"setup_type": "add_gateway"}, + {"next_step_id": "credentials"}, ) # Gateway ID not starting with * @@ -213,9 +223,11 @@ async def test_credentials_already_configured( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] is FlowResultType.MENU + result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"setup_type": "add_gateway"}, + {"next_step_id": "credentials"}, ) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -229,139 +241,76 @@ async def test_credentials_already_configured( assert result["reason"] == "already_configured" -async def test_credentials_cannot_connect(hass: HomeAssistant) -> None: - """Test credentials step when connection fails.""" - with patch( - "homeassistant.components.threema.client.Connection", autospec=True - ) as connection_class: - connection = MagicMock() - connection.__aenter__ = AsyncMock(return_value=connection) - connection.__aexit__ = AsyncMock(return_value=None) - connection.get_credits = AsyncMock( - side_effect=GatewayError("Connection refused") - ) - connection_class.return_value = connection - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"setup_type": "add_gateway"}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_GATEWAY_ID: MOCK_GATEWAY_ID, - CONF_API_SECRET: MOCK_API_SECRET, - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_credentials_server_error_non_auth(hass: HomeAssistant) -> None: - """Test credentials step with non-401 server error.""" - with patch( - "homeassistant.components.threema.client.Connection", autospec=True - ) as connection_class: - connection = MagicMock() - connection.__aenter__ = AsyncMock(return_value=connection) - connection.__aexit__ = AsyncMock(return_value=None) - connection.get_credits = AsyncMock(side_effect=GatewayServerError(status=500)) - connection_class.return_value = connection - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"setup_type": "add_gateway"}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_GATEWAY_ID: MOCK_GATEWAY_ID, - CONF_API_SECRET: MOCK_API_SECRET, - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_credentials_invalid_auth(hass: HomeAssistant) -> None: - """Test credentials step with invalid authentication.""" - with patch( - "homeassistant.components.threema.config_flow.ThreemaAPIClient.validate_credentials", - side_effect=ThreemaAuthError("Invalid credentials"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"setup_type": "add_gateway"}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_GATEWAY_ID: MOCK_GATEWAY_ID, - CONF_API_SECRET: MOCK_API_SECRET, - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_auth"} +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (GatewayError("Connection refused"), "cannot_connect"), + (GatewayServerError(status=500), "cannot_connect"), + (GatewayServerError(status=401), "invalid_auth"), + (RuntimeError("Unexpected"), "unknown"), + ], + ids=["cannot_connect", "server_error_non_auth", "invalid_auth", "unknown_error"], +) +async def test_credentials_error( + hass: HomeAssistant, + mock_connection: MagicMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test credentials step with various errors.""" + mock_connection.get_credits.side_effect = side_effect + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU -async def test_credentials_unknown_error(hass: HomeAssistant) -> None: - """Test credentials step with unexpected error.""" - with patch( - "homeassistant.components.threema.config_flow.ThreemaAPIClient.validate_credentials", - side_effect=RuntimeError("Unexpected"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"setup_type": "add_gateway"}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_GATEWAY_ID: MOCK_GATEWAY_ID, - CONF_API_SECRET: MOCK_API_SECRET, - }, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "unknown"} + assert result["errors"] == {"base": expected_error} -async def test_reauth_flow_invalid_auth( +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (GatewayServerError(status=401), "invalid_auth"), + (GatewayError("Connection refused"), "cannot_connect"), + (RuntimeError("Unexpected"), "unknown"), + ], + ids=["invalid_auth", "cannot_connect", "unknown_error"], +) +async def test_reauth_flow_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + side_effect: Exception, + expected_error: str, ) -> None: - """Test reauth flow with invalid credentials.""" + """Test reauth flow with various errors.""" mock_config_entry.add_to_hass(hass) + mock_connection.get_credits.side_effect = side_effect - with patch( - "homeassistant.components.threema.config_flow.ThreemaAPIClient.validate_credentials", - side_effect=ThreemaAuthError("Invalid credentials"), - ): - result = await mock_config_entry.start_reauth_flow(hass) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_API_SECRET: "wrong_secret", - }, - ) + result = await mock_config_entry.start_reauth_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_SECRET: "wrong_secret", + }, + ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_auth"} + assert result["errors"] == {"base": expected_error} async def test_reauth_flow_success( @@ -413,59 +362,6 @@ async def test_reauth_flow_preserves_private_key( assert mock_config_entry_with_keys.data[CONF_PRIVATE_KEY] == MOCK_PRIVATE_KEY -async def test_reauth_flow_cannot_connect( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """Test reauth flow with connection failure.""" - mock_config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.threema.client.Connection", autospec=True - ) as connection_class: - connection = MagicMock() - connection.__aenter__ = AsyncMock(return_value=connection) - connection.__aexit__ = AsyncMock(return_value=None) - connection.get_credits = AsyncMock( - side_effect=GatewayError("Connection refused") - ) - connection_class.return_value = connection - - result = await mock_config_entry.start_reauth_flow(hass) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_API_SECRET: "new_secret", - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_reauth_flow_unknown_error( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """Test reauth flow with unexpected error.""" - mock_config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.threema.config_flow.ThreemaAPIClient.validate_credentials", - side_effect=RuntimeError("Unexpected"), - ): - result = await mock_config_entry.start_reauth_flow(hass) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_API_SECRET: "new_secret", - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "unknown"} - - async def test_reauth_flow_updates_private_key( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -492,7 +388,7 @@ async def test_subentry_add_recipient( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_connection: MagicMock, - mock_send: MagicMock, + mock_send: tuple[MagicMock, MagicMock], ) -> None: """Test adding a recipient via subentry flow.""" mock_config_entry.add_to_hass(hass) @@ -520,7 +416,7 @@ async def test_subentry_invalid_recipient_id( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_connection: MagicMock, - mock_send: MagicMock, + mock_send: tuple[MagicMock, MagicMock], ) -> None: """Test subentry flow rejects invalid Threema ID.""" mock_config_entry.add_to_hass(hass) @@ -551,17 +447,34 @@ async def test_subentry_invalid_recipient_id( async def test_subentry_duplicate_recipient( hass: HomeAssistant, - mock_config_entry_with_subentry: MockConfigEntry, mock_connection: MagicMock, - mock_send: MagicMock, + mock_send: tuple[MagicMock, MagicMock], ) -> None: """Test subentry flow rejects duplicate recipient.""" - mock_config_entry_with_subentry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry_with_subentry.entry_id) + entry = MockConfigEntry( + title=f"Threema {MOCK_GATEWAY_ID}", + domain=DOMAIN, + data={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + unique_id=MOCK_GATEWAY_ID, + subentries_data=[ + { + "data": {CONF_RECIPIENT: "ABCD1234"}, + "subentry_id": "mock_subentry_id", + "subentry_type": SUBENTRY_TYPE_RECIPIENT, + "title": "ABCD1234", + "unique_id": "ABCD1234", + }, + ], + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() result = await hass.config_entries.subentries.async_init( - (mock_config_entry_with_subentry.entry_id, SUBENTRY_TYPE_RECIPIENT), + (entry.entry_id, SUBENTRY_TYPE_RECIPIENT), context={"source": config_entries.SOURCE_USER}, ) diff --git a/tests/components/threema/test_init.py b/tests/components/threema/test_init.py index f61fb1f227f86e..961ac1f143017a 100644 --- a/tests/components/threema/test_init.py +++ b/tests/components/threema/test_init.py @@ -2,8 +2,9 @@ from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch +import pytest from threema.gateway import GatewayError from threema.gateway.exception import GatewayServerError @@ -17,7 +18,7 @@ async def test_setup_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_connection: MagicMock, - mock_send: MagicMock, + mock_send: tuple[MagicMock, MagicMock], ) -> None: """Test successful setup of a config entry.""" mock_config_entry.add_to_hass(hass) @@ -28,57 +29,37 @@ async def test_setup_entry( assert mock_config_entry.state is ConfigEntryState.LOADED -async def test_setup_entry_connection_error( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """Test setup retries on connection error (ConfigEntryNotReady).""" - mock_config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.threema.client.Connection", autospec=True - ) as connection_class: - connection = MagicMock() - connection.__aenter__ = AsyncMock(return_value=connection) - connection.__aexit__ = AsyncMock(return_value=None) - connection.get_credits = AsyncMock( - side_effect=GatewayError("Connection refused") - ) - connection_class.return_value = connection - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_setup_entry_auth_error( +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + (GatewayError("Connection refused"), ConfigEntryState.SETUP_RETRY), + (GatewayServerError(status=401), ConfigEntryState.SETUP_ERROR), + (GatewayServerError(status=500), ConfigEntryState.SETUP_RETRY), + ], + ids=["connection_error", "auth_error", "server_error_non_auth"], +) +async def test_setup_entry_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + side_effect: Exception, + expected_state: ConfigEntryState, ) -> None: - """Test setup fails on auth error (ConfigEntryAuthFailed).""" + """Test setup handles various errors correctly.""" mock_config_entry.add_to_hass(hass) + mock_connection.get_credits.side_effect = side_effect - with patch( - "homeassistant.components.threema.client.Connection", autospec=True - ) as connection_class: - connection = MagicMock() - connection.__aenter__ = AsyncMock(return_value=connection) - connection.__aexit__ = AsyncMock(return_value=None) - connection.get_credits = AsyncMock(side_effect=GatewayServerError(status=401)) - connection_class.return_value = connection - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() - assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert mock_config_entry.state is expected_state async def test_unload_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_connection: MagicMock, - mock_send: MagicMock, + mock_send: tuple[MagicMock, MagicMock], ) -> None: """Test unloading a config entry.""" mock_config_entry.add_to_hass(hass) @@ -92,33 +73,11 @@ async def test_unload_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED -async def test_setup_entry_server_error_non_auth( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """Test setup retries on non-401 server error.""" - mock_config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.threema.client.Connection", autospec=True - ) as connection_class: - connection = MagicMock() - connection.__aenter__ = AsyncMock(return_value=connection) - connection.__aexit__ = AsyncMock(return_value=None) - connection.get_credits = AsyncMock(side_effect=GatewayServerError(status=500)) - connection_class.return_value = connection - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - - async def test_update_listener_reloads( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_connection: MagicMock, - mock_send: MagicMock, + mock_send: tuple[MagicMock, MagicMock], ) -> None: """Test that update listener reloads the entry.""" mock_config_entry.add_to_hass(hass) diff --git a/tests/components/threema/test_notify.py b/tests/components/threema/test_notify.py index a42ae209cd0ed6..402da348995ea0 100644 --- a/tests/components/threema/test_notify.py +++ b/tests/components/threema/test_notify.py @@ -2,7 +2,7 @@ from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock import pytest from threema.gateway import GatewayError @@ -10,32 +10,54 @@ from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.components.threema.const import ( + CONF_API_SECRET, + CONF_GATEWAY_ID, CONF_RECIPIENT, + DOMAIN, SUBENTRY_TYPE_RECIPIENT, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .conftest import MOCK_GATEWAY_ID, MOCK_RECIPIENT_ID, MOCK_SUBENTRY_ID +from .conftest import ( + MOCK_API_SECRET, + MOCK_GATEWAY_ID, + MOCK_RECIPIENT_ID, + MOCK_SUBENTRY_ID, +) from tests.common import MockConfigEntry +RECIPIENT_SUBENTRY = { + "data": {CONF_RECIPIENT: MOCK_RECIPIENT_ID}, + "subentry_id": MOCK_SUBENTRY_ID, + "subentry_type": SUBENTRY_TYPE_RECIPIENT, + "title": MOCK_RECIPIENT_ID, + "unique_id": MOCK_RECIPIENT_ID, +} + + +@pytest.fixture +def mock_subentries(): + """Override: provide a recipient subentry for notify tests.""" + return [RECIPIENT_SUBENTRY] + async def test_notify_entity_created( hass: HomeAssistant, - mock_config_entry_with_subentry: MockConfigEntry, + mock_config_entry: MockConfigEntry, mock_connection: MagicMock, - mock_send: MagicMock, + mock_send: tuple[MagicMock, MagicMock], + entity_registry: er.EntityRegistry, ) -> None: """Test notify entity is created from subentry.""" - mock_config_entry_with_subentry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry_with_subentry.entry_id) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - entity_registry = er.async_get(hass) entities = er.async_entries_for_config_entry( - entity_registry, mock_config_entry_with_subentry.entry_id + entity_registry, mock_config_entry.entry_id ) notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] assert len(notify_entities) == 1 @@ -44,37 +66,43 @@ async def test_notify_entity_created( async def test_notify_entity_not_created_without_subentry( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, mock_connection: MagicMock, - mock_send: MagicMock, + mock_send: tuple[MagicMock, MagicMock], + entity_registry: er.EntityRegistry, ) -> None: """Test no notify entity without subentries.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) + entry = MockConfigEntry( + title=f"Threema {MOCK_GATEWAY_ID}", + domain=DOMAIN, + data={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + unique_id=MOCK_GATEWAY_ID, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - entity_registry = er.async_get(hass) - entities = er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) + entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id) notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] assert len(notify_entities) == 0 async def test_send_message_simple( hass: HomeAssistant, - mock_config_entry_with_subentry: MockConfigEntry, + mock_config_entry: MockConfigEntry, mock_connection: MagicMock, - mock_send: MagicMock, + mock_send: tuple[MagicMock, MagicMock], + entity_registry: er.EntityRegistry, ) -> None: """Test sending a message via notify entity (simple mode).""" - mock_config_entry_with_subentry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry_with_subentry.entry_id) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - entity_registry = er.async_get(hass) entities = er.async_entries_for_config_entry( - entity_registry, mock_config_entry_with_subentry.entry_id + entity_registry, mock_config_entry.entry_id ) notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] assert len(notify_entities) == 1 @@ -86,37 +114,32 @@ async def test_send_message_simple( blocking=True, ) - mock_send.simple.assert_called_once() - mock_send.simple.return_value.send.assert_awaited_once() + mock_send[1].assert_called_once() + call_kwargs = mock_send[1].call_args[1] + assert call_kwargs["to_id"] == MOCK_RECIPIENT_ID + assert call_kwargs["text"] == "Hello from tests!" + mock_send[1].return_value.send.assert_awaited_once() async def test_send_message_e2e( hass: HomeAssistant, mock_config_entry_with_keys: MockConfigEntry, mock_connection: MagicMock, - mock_send: MagicMock, + mock_send: tuple[MagicMock, MagicMock], + entity_registry: er.EntityRegistry, ) -> None: """Test sending a message via notify entity (E2E mode).""" entry = MockConfigEntry( title=f"Threema {MOCK_GATEWAY_ID}", - domain="threema", + domain=DOMAIN, data=mock_config_entry_with_keys.data, unique_id=MOCK_GATEWAY_ID, - subentries_data=[ - { - "data": {CONF_RECIPIENT: MOCK_RECIPIENT_ID}, - "subentry_id": MOCK_SUBENTRY_ID, - "subentry_type": SUBENTRY_TYPE_RECIPIENT, - "title": MOCK_RECIPIENT_ID, - "unique_id": MOCK_RECIPIENT_ID, - }, - ], + subentries_data=[RECIPIENT_SUBENTRY], ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - entity_registry = er.async_get(hass) entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id) notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] assert len(notify_entities) == 1 @@ -128,113 +151,50 @@ async def test_send_message_e2e( blocking=True, ) - mock_send.e2e.assert_called_once() - mock_send.e2e.return_value.send.assert_awaited_once() - - -async def test_send_message_auth_error( - hass: HomeAssistant, - mock_config_entry_with_subentry: MockConfigEntry, - mock_connection: MagicMock, -) -> None: - """Test notify entity handles auth failure during send.""" - mock_config_entry_with_subentry.add_to_hass(hass) - - with patch( - "homeassistant.components.threema.client.SimpleTextMessage", autospec=True - ) as simple_mock: - simple_instance = MagicMock() - simple_instance.send = AsyncMock(side_effect=GatewayServerError(status=401)) - simple_mock.return_value = simple_instance - - await hass.config_entries.async_setup(mock_config_entry_with_subentry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - entities = er.async_entries_for_config_entry( - entity_registry, mock_config_entry_with_subentry.entry_id - ) - notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - NOTIFY_DOMAIN, - "send_message", - { - "entity_id": notify_entities[0].entity_id, - "message": "Hello!", - }, - blocking=True, - ) - - -async def test_send_message_send_error( + mock_send[0].assert_called_once() + call_kwargs = mock_send[0].call_args[1] + assert call_kwargs["to_id"] == MOCK_RECIPIENT_ID + assert call_kwargs["text"] == "Hello E2E!" + mock_send[0].return_value.send.assert_awaited_once() + + +@pytest.mark.parametrize( + ("side_effect", "match"), + [ + (GatewayServerError(status=401), "send_error"), + (GatewayError("Network error"), "send_error"), + (GatewayServerError(status=500), "send_error"), + ], + ids=["auth_error", "send_error", "server_error_non_auth"], +) +async def test_send_message_error( hass: HomeAssistant, - mock_config_entry_with_subentry: MockConfigEntry, + mock_config_entry: MockConfigEntry, mock_connection: MagicMock, + mock_send: tuple[MagicMock, MagicMock], + entity_registry: er.EntityRegistry, + side_effect: Exception, + match: str, ) -> None: - """Test notify entity handles send failure.""" - mock_config_entry_with_subentry.add_to_hass(hass) - - with patch( - "homeassistant.components.threema.client.SimpleTextMessage", autospec=True - ) as simple_mock: - simple_instance = MagicMock() - simple_instance.send = AsyncMock(side_effect=GatewayError("Network error")) - simple_mock.return_value = simple_instance - - await hass.config_entries.async_setup(mock_config_entry_with_subentry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - entities = er.async_entries_for_config_entry( - entity_registry, mock_config_entry_with_subentry.entry_id - ) - notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + """Test notify entity handles send errors.""" + mock_send[1].return_value.send.side_effect = side_effect - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - NOTIFY_DOMAIN, - "send_message", - { - "entity_id": notify_entities[0].entity_id, - "message": "Hello!", - }, - blocking=True, - ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] -async def test_send_message_server_error_non_auth( - hass: HomeAssistant, - mock_config_entry_with_subentry: MockConfigEntry, - mock_connection: MagicMock, -) -> None: - """Test notify entity handles non-401 server error.""" - mock_config_entry_with_subentry.add_to_hass(hass) - - with patch( - "homeassistant.components.threema.client.SimpleTextMessage", autospec=True - ) as simple_mock: - simple_instance = MagicMock() - simple_instance.send = AsyncMock(side_effect=GatewayServerError(status=500)) - simple_mock.return_value = simple_instance - - await hass.config_entries.async_setup(mock_config_entry_with_subentry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - entities = er.async_entries_for_config_entry( - entity_registry, mock_config_entry_with_subentry.entry_id + with pytest.raises(HomeAssistantError, match=match): + await hass.services.async_call( + NOTIFY_DOMAIN, + "send_message", + { + "entity_id": notify_entities[0].entity_id, + "message": "Hello!", + }, + blocking=True, ) - notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - NOTIFY_DOMAIN, - "send_message", - { - "entity_id": notify_entities[0].entity_id, - "message": "Hello!", - }, - blocking=True, - ) From 1b376aa5eb0251ecca23dcf9729405ee9ef66075 Mon Sep 17 00:00:00 2001 From: LukasQ Date: Thu, 2 Apr 2026 09:51:17 +0200 Subject: [PATCH 30/40] Fix failing tests --- tests/components/threema/test_notify.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/threema/test_notify.py b/tests/components/threema/test_notify.py index 402da348995ea0..4a9ffd2da59bfe 100644 --- a/tests/components/threema/test_notify.py +++ b/tests/components/threema/test_notify.py @@ -161,9 +161,9 @@ async def test_send_message_e2e( @pytest.mark.parametrize( ("side_effect", "match"), [ - (GatewayServerError(status=401), "send_error"), - (GatewayError("Network error"), "send_error"), - (GatewayServerError(status=500), "send_error"), + (GatewayServerError(status=401), "Error sending message"), + (GatewayError("Network error"), "Error sending message"), + (GatewayServerError(status=500), "Error sending message"), ], ids=["auth_error", "send_error", "server_error_non_auth"], ) From 566b9a83444a1fd5a9aa8eaeeae6d2db532c9fed Mon Sep 17 00:00:00 2001 From: LukasQ Date: Thu, 2 Apr 2026 12:45:44 +0200 Subject: [PATCH 31/40] stripped reauth for now --- homeassistant/components/threema/README.md | 37 ++---- .../components/threema/config_flow.py | 57 ---------- .../components/threema/manifest.json | 2 +- .../components/threema/quality_scale.yaml | 2 +- homeassistant/components/threema/strings.json | 15 +-- tests/components/threema/test_config_flow.py | 105 +----------------- 6 files changed, 12 insertions(+), 206 deletions(-) diff --git a/homeassistant/components/threema/README.md b/homeassistant/components/threema/README.md index ff3950904b94c7..22f109290a4a75 100644 --- a/homeassistant/components/threema/README.md +++ b/homeassistant/components/threema/README.md @@ -1,40 +1,19 @@ # Threema Integration -Threema Gateway integration for Home Assistant. Sends E2E encrypted or simple text messages via the [Threema Gateway](https://gateway.threema.ch/) service. +Developer notes for the Threema Gateway integration. -User-facing documentation: https://www.home-assistant.io/integrations/threema +## Deferred Features (follow-up PRs) -## Architecture +### Path to Silver +- **Reauthentication flow** — re-enter credentials when they become invalid (branch `feature/threema-reauth`) -- **`__init__.py`** — Config entry setup/unload, credential validation on startup -- **`config_flow.py`** — Multi-step flow: setup type selection, optional key generation, credential entry with validation, reauthentication. Subentry flow for adding recipients. -- **`notify.py`** — `NotifyEntity` per recipient (configured via subentries). Sends text messages via the Threema SDK. Supports optional title (prepended as bold text). -- **`client.py`** — `ThreemaAPIClient` wrapping the [threema.gateway SDK](https://github.com/threema-ch/threema-msgapi-sdk-python). Handles E2E (`TextMessage`) and simple (`SimpleTextMessage`) modes. Custom exceptions: `ThreemaAuthError`, `ThreemaConnectionError`, `ThreemaSendError` -- **`const.py`** — Domain and config key constants - -## Dependencies - -- `threema.gateway==8.0.0` — [Official Threema Gateway SDK](https://github.com/threema-ch/threema-msgapi-sdk-python) (MIT) - -## Design Decisions - -- **NotifyEntity with subentries** — Each recipient is a subentry creating a `NotifyEntity`. This integrates with HA's notify groups (send to Threema + Telegram + Alexa in one action) and follows the platform-first architecture. -- **Encryption downgrade protection** — Reauth flow preserves existing private keys when the field is left empty, preventing silent downgrade from E2E to simple mode. -- **Recipient validation** — Subentry flow validates Threema IDs with `^[0-9A-Za-z]{8}$` regex and normalizes to uppercase. -- **client.py as glue code** — The SDK handles all API communication. client.py maps SDK exceptions to HA-specific types and manages connection context. - -## Quality Scale - -Silver. See `quality_scale.yaml` for full status. - -## Roadmap - -### Path to Gold (quality scale blockers) +### Path to Gold +- **QR code image entity** — gateway identity verification via `3mid:` format (branch `feature/threema-qr`, requires `qrcode` dependency discussion) - **Diagnostics platform** — expose gateway info, credits, configured recipients -- **Reconfiguration flow** — edit recipient name/Threema ID after creation (subentry reconfigure step). Currently: delete and re-add. +- **Reconfiguration flow** — edit recipient name/Threema ID after creation (subentry reconfigure step) ### Future features -- QR code image entity for gateway identity verification (follow-up PR, branch `feature/threema-qr`) - Incoming messages via Gateway callback webhooks - Image/file support (requires new platform or service — `NotifyEntity` only supports text + title) - Remaining credits sensor +- TextSelector with PASSWORD type for secret fields diff --git a/homeassistant/components/threema/config_flow.py b/homeassistant/components/threema/config_flow.py index 8425c2f50a0fea..961e480595e296 100644 --- a/homeassistant/components/threema/config_flow.py +++ b/homeassistant/components/threema/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Mapping import logging from typing import Any @@ -168,62 +167,6 @@ async def async_step_credentials( errors=errors, ) - async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult: - """Handle reauth if credentials become invalid.""" - return await self.async_step_reauth_confirm() - - async def async_step_reauth_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle reauth confirmation.""" - errors: dict[str, str] = {} - - if user_input is not None: - reauth_entry = self._get_reauth_entry() - api_secret = user_input[CONF_API_SECRET].strip() - private_key_input = user_input.get(CONF_PRIVATE_KEY, "").strip() or None - - client = ThreemaAPIClient( - self.hass, - gateway_id=reauth_entry.data[CONF_GATEWAY_ID], - api_secret=api_secret, - private_key=private_key_input - or reauth_entry.data.get(CONF_PRIVATE_KEY), - ) - - try: - await client.validate_credentials() - except ThreemaAuthError: - errors["base"] = "invalid_auth" - except ThreemaConnectionError: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected error validating new credentials") - errors["base"] = "unknown" - else: - await self.async_set_unique_id(reauth_entry.data[CONF_GATEWAY_ID]) - self._abort_if_unique_id_mismatch() - data_updates: dict[str, str] = { - CONF_API_SECRET: api_secret, - } - if private_key_input: - data_updates[CONF_PRIVATE_KEY] = private_key_input - return self.async_update_reload_and_abort( - reauth_entry, - data_updates=data_updates, - ) - - return self.async_show_form( - step_id="reauth_confirm", - data_schema=vol.Schema( - { - vol.Required(CONF_API_SECRET): str, - vol.Optional(CONF_PRIVATE_KEY): str, - } - ), - errors=errors, - ) - RECIPIENT_SCHEMA = vol.All( cv.string, diff --git a/homeassistant/components/threema/manifest.json b/homeassistant/components/threema/manifest.json index 1840c83e6dbe7e..c0223742496dfb 100644 --- a/homeassistant/components/threema/manifest.json +++ b/homeassistant/components/threema/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_push", "loggers": ["threema"], - "quality_scale": "silver", + "quality_scale": "bronze", "requirements": ["threema.gateway==8.0.0"] } diff --git a/homeassistant/components/threema/quality_scale.yaml b/homeassistant/components/threema/quality_scale.yaml index 046c8ff571b4df..f6dbbf364a097a 100644 --- a/homeassistant/components/threema/quality_scale.yaml +++ b/homeassistant/components/threema/quality_scale.yaml @@ -38,7 +38,7 @@ rules: parallel-updates: status: exempt comment: No polling or data updates. - reauthentication-flow: done + reauthentication-flow: todo test-coverage: done # Gold diff --git a/homeassistant/components/threema/strings.json b/homeassistant/components/threema/strings.json index d645323c96461a..36541d919784ae 100644 --- a/homeassistant/components/threema/strings.json +++ b/homeassistant/components/threema/strings.json @@ -2,8 +2,7 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "key_generation_failed": "Failed to generate encryption keys.", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "key_generation_failed": "Failed to generate encryption keys." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -28,18 +27,6 @@ "description": "Enter your Threema Gateway ID and secret obtained from gateway.threema.ch.", "title": "Gateway credentials" }, - "reauth_confirm": { - "data": { - "api_secret": "[%key:component::threema::config::step::credentials::data::api_secret%]", - "private_key": "[%key:component::threema::config::step::credentials::data::private_key%]" - }, - "data_description": { - "api_secret": "Enter the new API secret from gateway.threema.ch.", - "private_key": "Private key, including 'private:' prefix" - }, - "description": "Your Threema Gateway credentials need to be updated.", - "title": "[%key:common::config_flow::title::reauth%]" - }, "setup_new": { "data": { "private_key": "Private key", diff --git a/tests/components/threema/test_config_flow.py b/tests/components/threema/test_config_flow.py index 95ff8912b4449d..20b2bb3b51306d 100644 --- a/tests/components/threema/test_config_flow.py +++ b/tests/components/threema/test_config_flow.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import MOCK_API_SECRET, MOCK_GATEWAY_ID, MOCK_PRIVATE_KEY +from .conftest import MOCK_API_SECRET, MOCK_GATEWAY_ID from tests.common import MockConfigEntry @@ -281,109 +281,6 @@ async def test_credentials_error( assert result["errors"] == {"base": expected_error} -@pytest.mark.parametrize( - ("side_effect", "expected_error"), - [ - (GatewayServerError(status=401), "invalid_auth"), - (GatewayError("Connection refused"), "cannot_connect"), - (RuntimeError("Unexpected"), "unknown"), - ], - ids=["invalid_auth", "cannot_connect", "unknown_error"], -) -async def test_reauth_flow_error( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_connection: MagicMock, - side_effect: Exception, - expected_error: str, -) -> None: - """Test reauth flow with various errors.""" - mock_config_entry.add_to_hass(hass) - mock_connection.get_credits.side_effect = side_effect - - result = await mock_config_entry.start_reauth_flow(hass) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_API_SECRET: "wrong_secret", - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": expected_error} - - -async def test_reauth_flow_success( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_connection: MagicMock, -) -> None: - """Test reauth flow succeeds with valid credentials.""" - mock_config_entry.add_to_hass(hass) - - result = await mock_config_entry.start_reauth_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_API_SECRET: "new_secret", - }, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - assert mock_config_entry.data[CONF_API_SECRET] == "new_secret" - - -async def test_reauth_flow_preserves_private_key( - hass: HomeAssistant, - mock_config_entry_with_keys: MockConfigEntry, - mock_connection: MagicMock, -) -> None: - """Test reauth flow preserves existing private key when not provided.""" - mock_config_entry_with_keys.add_to_hass(hass) - - result = await mock_config_entry_with_keys.start_reauth_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_API_SECRET: "new_secret", - }, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - assert mock_config_entry_with_keys.data[CONF_API_SECRET] == "new_secret" - assert mock_config_entry_with_keys.data[CONF_PRIVATE_KEY] == MOCK_PRIVATE_KEY - - -async def test_reauth_flow_updates_private_key( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_connection: MagicMock, -) -> None: - """Test reauth flow updates private key when provided.""" - mock_config_entry.add_to_hass(hass) - - result = await mock_config_entry.start_reauth_flow(hass) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_API_SECRET: "new_secret", - CONF_PRIVATE_KEY: "private:newkey123", - }, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - assert mock_config_entry.data[CONF_PRIVATE_KEY] == "private:newkey123" - - async def test_subentry_add_recipient( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 9854c566099bb63a6d0b66a7df6510555299594b Mon Sep 17 00:00:00 2001 From: LukasQ Date: Thu, 2 Apr 2026 12:58:09 +0200 Subject: [PATCH 32/40] Improved testing further --- .../components/threema/config_flow.py | 35 +++++++++---------- tests/components/threema/test_config_flow.py | 4 ++- tests/components/threema/test_notify.py | 3 +- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/threema/config_flow.py b/homeassistant/components/threema/config_flow.py index 961e480595e296..bda4809951ec33 100644 --- a/homeassistant/components/threema/config_flow.py +++ b/homeassistant/components/threema/config_flow.py @@ -185,30 +185,27 @@ async def async_step_user( errors: dict[str, str] = {} if user_input is not None: - try: - recipient_id = RECIPIENT_SCHEMA(user_input[CONF_RECIPIENT]) - except vol.Invalid: - errors["base"] = "invalid_recipient_id" - else: - # Check for duplicate recipients - for subentry in self._get_entry().subentries.values(): - if subentry.data.get(CONF_RECIPIENT) == recipient_id: - return self.async_abort(reason="already_configured") - - raw_name = user_input.get(CONF_NAME, "").strip() - name = f"{raw_name} ({recipient_id})" if raw_name else recipient_id - - return self.async_create_entry( - title=name, - data={CONF_RECIPIENT: recipient_id}, - unique_id=recipient_id, - ) + recipient_id = user_input[CONF_RECIPIENT] + + # Check for duplicate recipients + for subentry in self._get_entry().subentries.values(): + if subentry.data.get(CONF_RECIPIENT) == recipient_id: + return self.async_abort(reason="already_configured") + + raw_name = user_input.get(CONF_NAME, "").strip() + name = f"{raw_name} ({recipient_id})" if raw_name else recipient_id + + return self.async_create_entry( + title=name, + data={CONF_RECIPIENT: recipient_id}, + unique_id=recipient_id, + ) return self.async_show_form( step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_RECIPIENT): str, + vol.Required(CONF_RECIPIENT): RECIPIENT_SCHEMA, vol.Optional(CONF_NAME): str, } ), diff --git a/tests/components/threema/test_config_flow.py b/tests/components/threema/test_config_flow.py index 20b2bb3b51306d..7a167c4031105e 100644 --- a/tests/components/threema/test_config_flow.py +++ b/tests/components/threema/test_config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest @@ -27,7 +28,7 @@ @pytest.fixture(autouse=True) -def mock_setup_entry(): +def mock_setup_entry() -> Generator[None]: """Patch async_setup_entry to avoid full setup during flow tests.""" with patch("homeassistant.components.threema.async_setup_entry", return_value=True): yield @@ -307,6 +308,7 @@ async def test_subentry_add_recipient( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "ABCD1234" assert result["data"] == {CONF_RECIPIENT: "ABCD1234"} + assert result["unique_id"] == "ABCD1234" async def test_subentry_invalid_recipient_id( diff --git a/tests/components/threema/test_notify.py b/tests/components/threema/test_notify.py index 4a9ffd2da59bfe..b5c3fe0b2eda79 100644 --- a/tests/components/threema/test_notify.py +++ b/tests/components/threema/test_notify.py @@ -2,6 +2,7 @@ from __future__ import annotations +from typing import Any from unittest.mock import MagicMock import pytest @@ -39,7 +40,7 @@ @pytest.fixture -def mock_subentries(): +def mock_subentries() -> list[dict[str, Any]]: """Override: provide a recipient subentry for notify tests.""" return [RECIPIENT_SUBENTRY] From 4bd3a43e413692440ff7faf715ed3302e57c6a0b Mon Sep 17 00:00:00 2001 From: LukasQ Date: Thu, 2 Apr 2026 13:00:11 +0200 Subject: [PATCH 33/40] full invalid journey in tests --- tests/components/threema/test_config_flow.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/components/threema/test_config_flow.py b/tests/components/threema/test_config_flow.py index 7a167c4031105e..38b1a0f918ea4f 100644 --- a/tests/components/threema/test_config_flow.py +++ b/tests/components/threema/test_config_flow.py @@ -212,6 +212,17 @@ async def test_credentials_invalid_gateway_id( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "invalid_gateway_id"} + # Valid Gateway ID — recover and create entry + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == MOCK_GATEWAY_ID + async def test_credentials_already_configured( hass: HomeAssistant, From 783a641dca49f9ca19e568ce6f5a8bec1be2b0ad Mon Sep 17 00:00:00 2001 From: LukasQ Date: Thu, 2 Apr 2026 13:07:32 +0200 Subject: [PATCH 34/40] closed 100% configflow codecov --- tests/components/threema/test_config_flow.py | 27 ++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/components/threema/test_config_flow.py b/tests/components/threema/test_config_flow.py index 38b1a0f918ea4f..03fbf81141c797 100644 --- a/tests/components/threema/test_config_flow.py +++ b/tests/components/threema/test_config_flow.py @@ -322,6 +322,33 @@ async def test_subentry_add_recipient( assert result["unique_id"] == "ABCD1234" +async def test_subentry_add_recipient_with_name( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: tuple[MagicMock, MagicMock], +) -> None: + """Test adding a recipient with a display name.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_RECIPIENT), + context={"source": config_entries.SOURCE_USER}, + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: "ABCD1234", "name": "Dad"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Dad (ABCD1234)" + assert result["data"] == {CONF_RECIPIENT: "ABCD1234"} + assert result["unique_id"] == "ABCD1234" + + async def test_subentry_invalid_recipient_id( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 7b87a19181c6a7f7f14b158a52a6bebefeea9d82 Mon Sep 17 00:00:00 2001 From: LukasQ Date: Thu, 2 Apr 2026 13:27:36 +0200 Subject: [PATCH 35/40] corrected test --- tests/components/threema/test_config_flow.py | 30 +++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/tests/components/threema/test_config_flow.py b/tests/components/threema/test_config_flow.py index 03fbf81141c797..5acc1c39906289 100644 --- a/tests/components/threema/test_config_flow.py +++ b/tests/components/threema/test_config_flow.py @@ -20,7 +20,7 @@ SUBENTRY_TYPE_RECIPIENT, ) from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.data_entry_flow import FlowResultType, InvalidData from .conftest import MOCK_API_SECRET, MOCK_GATEWAY_ID @@ -349,13 +349,19 @@ async def test_subentry_add_recipient_with_name( assert result["unique_id"] == "ABCD1234" +@pytest.mark.parametrize( + "invalid_id", + ["ABC", "ABCD!@#$", ""], + ids=["too_short", "special_chars", "empty"], +) async def test_subentry_invalid_recipient_id( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_connection: MagicMock, mock_send: tuple[MagicMock, MagicMock], + invalid_id: str, ) -> None: - """Test subentry flow rejects invalid Threema ID.""" + """Test subentry flow rejects invalid Threema ID via schema validation.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -365,21 +371,11 @@ async def test_subentry_invalid_recipient_id( context={"source": config_entries.SOURCE_USER}, ) - # Too short - result = await hass.config_entries.subentries.async_configure( - result["flow_id"], - user_input={CONF_RECIPIENT: "ABC"}, - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_recipient_id"} - - # Special characters - result = await hass.config_entries.subentries.async_configure( - result["flow_id"], - user_input={CONF_RECIPIENT: "ABCD!@#$"}, - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_recipient_id"} + with pytest.raises(InvalidData): + await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: invalid_id}, + ) async def test_subentry_duplicate_recipient( From dd133d9b604bad3ed09155a1a6b03770ceb8f185 Mon Sep 17 00:00:00 2001 From: LukasQ Date: Tue, 7 Apr 2026 08:48:07 +0200 Subject: [PATCH 36/40] deleted remote --- homeassistant/components/threema/README.md | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 homeassistant/components/threema/README.md diff --git a/homeassistant/components/threema/README.md b/homeassistant/components/threema/README.md deleted file mode 100644 index 22f109290a4a75..00000000000000 --- a/homeassistant/components/threema/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Threema Integration - -Developer notes for the Threema Gateway integration. - -## Deferred Features (follow-up PRs) - -### Path to Silver -- **Reauthentication flow** — re-enter credentials when they become invalid (branch `feature/threema-reauth`) - -### Path to Gold -- **QR code image entity** — gateway identity verification via `3mid:` format (branch `feature/threema-qr`, requires `qrcode` dependency discussion) -- **Diagnostics platform** — expose gateway info, credits, configured recipients -- **Reconfiguration flow** — edit recipient name/Threema ID after creation (subentry reconfigure step) - -### Future features -- Incoming messages via Gateway callback webhooks -- Image/file support (requires new platform or service — `NotifyEntity` only supports text + title) -- Remaining credits sensor -- TextSelector with PASSWORD type for secret fields From 21671ee3354b88f8eaff48b8e82c2cee0c31d3ab Mon Sep 17 00:00:00 2001 From: LukasQ Date: Wed, 8 Apr 2026 07:29:42 +0000 Subject: [PATCH 37/40] updated exceptions --- homeassistant/components/threema/config_flow.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/threema/config_flow.py b/homeassistant/components/threema/config_flow.py index bda4809951ec33..f9d1e00d2ab4f2 100644 --- a/homeassistant/components/threema/config_flow.py +++ b/homeassistant/components/threema/config_flow.py @@ -80,12 +80,13 @@ async def async_step_setup_new( private_key, public_key = await self.hass.async_add_executor_job( generate_key_pair ) - self._private_key = private_key - self._public_key = public_key except Exception: _LOGGER.exception("Failed to generate key pair") return self.async_abort(reason="key_generation_failed") + self._private_key = private_key + self._public_key = public_key + return self.async_show_form( step_id="setup_new", data_schema=vol.Schema( From e546ae734d5a7f74391e9d36c1a2db0bc7f82bab Mon Sep 17 00:00:00 2001 From: LukasQ Date: Wed, 8 Apr 2026 09:03:51 +0000 Subject: [PATCH 38/40] removed key editing in setup phase --- homeassistant/components/threema/config_flow.py | 14 ++++---------- homeassistant/components/threema/strings.json | 10 +--------- tests/components/threema/test_config_flow.py | 5 +---- 3 files changed, 6 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/threema/config_flow.py b/homeassistant/components/threema/config_flow.py index f9d1e00d2ab4f2..2944b07899d471 100644 --- a/homeassistant/components/threema/config_flow.py +++ b/homeassistant/components/threema/config_flow.py @@ -70,10 +70,6 @@ async def async_step_setup_new( ) -> ConfigFlowResult: """Generate keys for a new Gateway ID.""" if user_input is not None: - if user_input.get(CONF_PRIVATE_KEY): - self._private_key = user_input[CONF_PRIVATE_KEY] - if user_input.get(CONF_PUBLIC_KEY): - self._public_key = user_input[CONF_PUBLIC_KEY] return await self.async_step_credentials() try: @@ -89,12 +85,10 @@ async def async_step_setup_new( return self.async_show_form( step_id="setup_new", - data_schema=vol.Schema( - { - vol.Optional(CONF_PUBLIC_KEY, default=public_key): str, - vol.Optional(CONF_PRIVATE_KEY, default=private_key): str, - } - ), + description_placeholders={ + "public_key": public_key, + "private_key": private_key, + }, ) async def async_step_credentials( diff --git a/homeassistant/components/threema/strings.json b/homeassistant/components/threema/strings.json index 36541d919784ae..92955a14394107 100644 --- a/homeassistant/components/threema/strings.json +++ b/homeassistant/components/threema/strings.json @@ -28,15 +28,7 @@ "title": "Gateway credentials" }, "setup_new": { - "data": { - "private_key": "Private key", - "public_key": "Public key" - }, - "data_description": { - "private_key": "Your generated private key. Store it securely.", - "public_key": "Your generated public key. Use when registering at gateway.threema.ch." - }, - "description": "Save your keys now! When registering at gateway.threema.ch, paste only the hex part of your public key (without the 'public:' prefix). Save your private key securely.", + "description": "Your keys have been generated. Save them now before continuing!\n\nPublic key: `{public_key}`\nPrivate key: `{private_key}`\n\nWhen registering at gateway.threema.ch, paste only the hex part of the public key (without the `public:` prefix). Store the private key securely.", "title": "Keys generated" }, "user": { diff --git a/tests/components/threema/test_config_flow.py b/tests/components/threema/test_config_flow.py index 5acc1c39906289..a42f8af40ccffd 100644 --- a/tests/components/threema/test_config_flow.py +++ b/tests/components/threema/test_config_flow.py @@ -133,10 +133,7 @@ async def test_user_flow_new_gateway( result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={ - "public_key": "public:generated_public_key_hex", - "private_key": "private:generated_private_key_hex", - }, + user_input={}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "credentials" From e403c0ab2b47fe1223992d8c97bf59cca7fbbb38 Mon Sep 17 00:00:00 2001 From: LukasQ Date: Wed, 8 Apr 2026 09:34:56 +0000 Subject: [PATCH 39/40] updated testing --- tests/components/threema/conftest.py | 19 +++- tests/components/threema/test_config_flow.py | 59 ++++++------- tests/components/threema/test_notify.py | 93 +++++++------------- 3 files changed, 73 insertions(+), 98 deletions(-) diff --git a/tests/components/threema/conftest.py b/tests/components/threema/conftest.py index 8f3ae576f625f1..1d4b10d87fe7e1 100644 --- a/tests/components/threema/conftest.py +++ b/tests/components/threema/conftest.py @@ -12,7 +12,9 @@ CONF_GATEWAY_ID, CONF_PRIVATE_KEY, CONF_PUBLIC_KEY, + CONF_RECIPIENT, DOMAIN, + SUBENTRY_TYPE_RECIPIENT, ) from homeassistant.config_entries import ConfigSubentryDataWithId @@ -29,11 +31,19 @@ MOCK_RECIPIENT_ID = "ABCD1234" MOCK_SUBENTRY_ID = "mock_subentry_id" +RECIPIENT_SUBENTRY: ConfigSubentryDataWithId = { + "data": {CONF_RECIPIENT: MOCK_RECIPIENT_ID}, + "subentry_id": MOCK_SUBENTRY_ID, + "subentry_type": SUBENTRY_TYPE_RECIPIENT, + "title": MOCK_RECIPIENT_ID, + "unique_id": MOCK_RECIPIENT_ID, +} + @pytest.fixture def mock_subentries() -> list[ConfigSubentryDataWithId]: - """Fixture for subentries, override in tests that need recipients.""" - return [] + """Fixture providing one recipient subentry by default; override to [] when not needed.""" + return [RECIPIENT_SUBENTRY] @pytest.fixture @@ -54,7 +64,9 @@ def mock_config_entry( @pytest.fixture -def mock_config_entry_with_keys() -> MockConfigEntry: +def mock_config_entry_with_keys( + mock_subentries: list[ConfigSubentryDataWithId], +) -> MockConfigEntry: """Return a mocked config entry for E2E encrypted mode.""" return MockConfigEntry( title=f"Threema {MOCK_GATEWAY_ID}", @@ -66,6 +78,7 @@ def mock_config_entry_with_keys() -> MockConfigEntry: CONF_PUBLIC_KEY: MOCK_PUBLIC_KEY, }, unique_id=MOCK_GATEWAY_ID, + subentries_data=[*mock_subentries], ) diff --git a/tests/components/threema/test_config_flow.py b/tests/components/threema/test_config_flow.py index a42f8af40ccffd..578a935a82538c 100644 --- a/tests/components/threema/test_config_flow.py +++ b/tests/components/threema/test_config_flow.py @@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType, InvalidData -from .conftest import MOCK_API_SECRET, MOCK_GATEWAY_ID +from .conftest import MOCK_API_SECRET, MOCK_GATEWAY_ID, MOCK_RECIPIENT_ID from tests.common import MockConfigEntry @@ -57,7 +57,6 @@ async def test_user_flow_existing_gateway( CONF_API_SECRET: MOCK_API_SECRET, }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == f"Threema {MOCK_GATEWAY_ID}" @@ -91,7 +90,6 @@ async def test_user_flow_existing_with_keys( CONF_PUBLIC_KEY: "public:1234567890abcdef", }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_PRIVATE_KEY] == "private:abcdef1234567890" @@ -145,7 +143,6 @@ async def test_user_flow_new_gateway( CONF_API_SECRET: MOCK_API_SECRET, }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_PRIVATE_KEY] == "private:generated_private_key_hex" @@ -289,6 +286,17 @@ async def test_credentials_error( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": expected_error} + mock_connection.get_credits.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + async def test_subentry_add_recipient( hass: HomeAssistant, @@ -310,13 +318,13 @@ async def test_subentry_add_recipient( result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_RECIPIENT: "ABCD1234"}, + user_input={CONF_RECIPIENT: "EFGH5678"}, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "ABCD1234" - assert result["data"] == {CONF_RECIPIENT: "ABCD1234"} - assert result["unique_id"] == "ABCD1234" + assert result["title"] == "EFGH5678" + assert result["data"] == {CONF_RECIPIENT: "EFGH5678"} + assert result["unique_id"] == "EFGH5678" async def test_subentry_add_recipient_with_name( @@ -337,13 +345,13 @@ async def test_subentry_add_recipient_with_name( result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_RECIPIENT: "ABCD1234", "name": "Dad"}, + user_input={CONF_RECIPIENT: "EFGH5678", "name": "Dad"}, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Dad (ABCD1234)" - assert result["data"] == {CONF_RECIPIENT: "ABCD1234"} - assert result["unique_id"] == "ABCD1234" + assert result["title"] == "Dad (EFGH5678)" + assert result["data"] == {CONF_RECIPIENT: "EFGH5678"} + assert result["unique_id"] == "EFGH5678" @pytest.mark.parametrize( @@ -377,40 +385,23 @@ async def test_subentry_invalid_recipient_id( async def test_subentry_duplicate_recipient( hass: HomeAssistant, + mock_config_entry: MockConfigEntry, mock_connection: MagicMock, mock_send: tuple[MagicMock, MagicMock], ) -> None: """Test subentry flow rejects duplicate recipient.""" - entry = MockConfigEntry( - title=f"Threema {MOCK_GATEWAY_ID}", - domain=DOMAIN, - data={ - CONF_GATEWAY_ID: MOCK_GATEWAY_ID, - CONF_API_SECRET: MOCK_API_SECRET, - }, - unique_id=MOCK_GATEWAY_ID, - subentries_data=[ - { - "data": {CONF_RECIPIENT: "ABCD1234"}, - "subentry_id": "mock_subentry_id", - "subentry_type": SUBENTRY_TYPE_RECIPIENT, - "title": "ABCD1234", - "unique_id": "ABCD1234", - }, - ], - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() result = await hass.config_entries.subentries.async_init( - (entry.entry_id, SUBENTRY_TYPE_RECIPIENT), + (mock_config_entry.entry_id, SUBENTRY_TYPE_RECIPIENT), context={"source": config_entries.SOURCE_USER}, ) result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_RECIPIENT: "ABCD1234"}, + user_input={CONF_RECIPIENT: MOCK_RECIPIENT_ID}, ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/threema/test_notify.py b/tests/components/threema/test_notify.py index b5c3fe0b2eda79..5f43d0bac5b689 100644 --- a/tests/components/threema/test_notify.py +++ b/tests/components/threema/test_notify.py @@ -2,7 +2,6 @@ from __future__ import annotations -from typing import Any from unittest.mock import MagicMock import pytest @@ -10,40 +9,14 @@ from threema.gateway.exception import GatewayServerError from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN -from homeassistant.components.threema.const import ( - CONF_API_SECRET, - CONF_GATEWAY_ID, - CONF_RECIPIENT, - DOMAIN, - SUBENTRY_TYPE_RECIPIENT, -) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .conftest import ( - MOCK_API_SECRET, - MOCK_GATEWAY_ID, - MOCK_RECIPIENT_ID, - MOCK_SUBENTRY_ID, -) +from .conftest import MOCK_GATEWAY_ID, MOCK_RECIPIENT_ID from tests.common import MockConfigEntry -RECIPIENT_SUBENTRY = { - "data": {CONF_RECIPIENT: MOCK_RECIPIENT_ID}, - "subentry_id": MOCK_SUBENTRY_ID, - "subentry_type": SUBENTRY_TYPE_RECIPIENT, - "title": MOCK_RECIPIENT_ID, - "unique_id": MOCK_RECIPIENT_ID, -} - - -@pytest.fixture -def mock_subentries() -> list[dict[str, Any]]: - """Override: provide a recipient subentry for notify tests.""" - return [RECIPIENT_SUBENTRY] - async def test_notify_entity_created( hass: HomeAssistant, @@ -65,29 +38,32 @@ async def test_notify_entity_created( assert notify_entities[0].unique_id == f"{MOCK_GATEWAY_ID}_{MOCK_RECIPIENT_ID}" -async def test_notify_entity_not_created_without_subentry( - hass: HomeAssistant, - mock_connection: MagicMock, - mock_send: tuple[MagicMock, MagicMock], - entity_registry: er.EntityRegistry, -) -> None: - """Test no notify entity without subentries.""" - entry = MockConfigEntry( - title=f"Threema {MOCK_GATEWAY_ID}", - domain=DOMAIN, - data={ - CONF_GATEWAY_ID: MOCK_GATEWAY_ID, - CONF_API_SECRET: MOCK_API_SECRET, - }, - unique_id=MOCK_GATEWAY_ID, - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id) - notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] - assert len(notify_entities) == 0 +class TestNotifyEntityNotCreatedWithoutSubentry: + """Tests that no entity is created when there are no subentries.""" + + @pytest.fixture + def mock_subentries(self) -> list: + """Override: no subentries.""" + return [] + + async def test_notify_entity_not_created_without_subentry( + self, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: tuple[MagicMock, MagicMock], + entity_registry: er.EntityRegistry, + ) -> None: + """Test no notify entity without subentries.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + assert len(notify_entities) == 0 async def test_send_message_simple( @@ -130,18 +106,13 @@ async def test_send_message_e2e( entity_registry: er.EntityRegistry, ) -> None: """Test sending a message via notify entity (E2E mode).""" - entry = MockConfigEntry( - title=f"Threema {MOCK_GATEWAY_ID}", - domain=DOMAIN, - data=mock_config_entry_with_keys.data, - unique_id=MOCK_GATEWAY_ID, - subentries_data=[RECIPIENT_SUBENTRY], - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) + mock_config_entry_with_keys.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_keys.entry_id) await hass.async_block_till_done() - entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry_with_keys.entry_id + ) notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] assert len(notify_entities) == 1 From 409c22640c03f4581fa0d7fad6ff34e72b471074 Mon Sep 17 00:00:00 2001 From: Joostlek Date: Fri, 10 Apr 2026 00:31:41 +0200 Subject: [PATCH 40/40] Fix --- tests/components/threema/test_notify.py | 44 ++++++++++--------------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/tests/components/threema/test_notify.py b/tests/components/threema/test_notify.py index 5f43d0bac5b689..d761861e791ef8 100644 --- a/tests/components/threema/test_notify.py +++ b/tests/components/threema/test_notify.py @@ -38,32 +38,24 @@ async def test_notify_entity_created( assert notify_entities[0].unique_id == f"{MOCK_GATEWAY_ID}_{MOCK_RECIPIENT_ID}" -class TestNotifyEntityNotCreatedWithoutSubentry: - """Tests that no entity is created when there are no subentries.""" - - @pytest.fixture - def mock_subentries(self) -> list: - """Override: no subentries.""" - return [] - - async def test_notify_entity_not_created_without_subentry( - self, - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_connection: MagicMock, - mock_send: tuple[MagicMock, MagicMock], - entity_registry: er.EntityRegistry, - ) -> None: - """Test no notify entity without subentries.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - entities = er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) - notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] - assert len(notify_entities) == 0 +@pytest.mark.parametrize("mock_subentries", [[]]) +async def test_notify_entity_not_created_without_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MagicMock, + mock_send: tuple[MagicMock, MagicMock], + entity_registry: er.EntityRegistry, +) -> None: + """Test no notify entity without subentries.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + assert len(notify_entities) == 0 async def test_send_message_simple(