diff --git a/.strict-typing b/.strict-typing index 09954a3b27ccd..90f0ed784f3c7 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 e092a83b12bec..5dc49c777f61c 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 0000000000000..5417fcb2a3830 --- /dev/null +++ b/homeassistant/components/threema/README.md @@ -0,0 +1,208 @@ +# Threema Gateway Integration for Home Assistant + +Send secure, end-to-end encrypted messages from Home Assistant to any Threema user using the Threema Gateway service. + +## What is Threema Gateway? + +[Threema Gateway](https://gateway.threema.ch/) allows you to send Threema messages programmatically. This integration enables Home Assistant to send notifications and alerts to Threema users. + +**Common Use Cases:** +- Security alerts (door/window sensors) +- Temperature warnings +- System notifications +- Daily summaries + +## Prerequisites + +1. **Threema Gateway Account** - Sign up at [gateway.threema.ch](https://gateway.threema.ch/) +2. **Gateway Credentials** - You'll receive a Gateway ID (starts with `*`) and API Secret +3. **Message Credits** - Purchase credits to send messages + +## Installation + +### Step 1: Add Integration + +1. Go to **Settings** > **Devices & Services** +2. Click **+ Add Integration** +3. Search for **"Threema"** +4. Follow the setup wizard + +### Step 2: Choose Setup Type + +#### Option A: Create NEW Gateway ID (Recommended for E2E) +1. Select "Create NEW Gateway ID" +2. Home Assistant generates a key pair +3. **Copy the public key** shown on screen (hex part only, without `public:` prefix) +4. Go to https://gateway.threema.ch/ and create an **End-to-End Gateway ID** +5. **Paste the public key** during registration +6. Return to Home Assistant, enter your new Gateway ID and API Secret +7. Keys are automatically stored + +#### Option B: Use EXISTING Gateway ID +1. Select "Use EXISTING Gateway ID" +2. Enter your Gateway ID and API Secret +3. **For E2E mode:** Also enter your private key (format: `private:abc123def456...`) +4. **Optionally:** Enter your public key (format: `public:def456abc123...`) +5. **For Basic mode:** Leave key fields empty + +**Key Format Examples:** +``` +Private Key: private:1a2b3c4d5e6f7890abcdef1234567890... +Public Key: public:9876543210fedcba0987654321fedcba... +``` + +## Usage + +### Service: `threema.send_message` + +This is the primary way to send messages. Use it in automations, scripts, or Developer Tools > Services. + +```yaml +service: threema.send_message +data: + recipient: "ABCD1234" # Threema ID of the recipient (8 characters) + message: "Hello from Home Assistant!" +``` + +The integration automatically uses your configured gateway. If you have multiple gateways, specify which one: +```yaml +service: threema.send_message +data: + config_entry_id: "01kh8feq7yz5qrj4d974sdg1w3" + recipient: "ABCD1234" + message: "Hello from Home Assistant!" +``` + +**How to find a Threema ID:** +- Open Threema app > Settings > My ID (8 characters, e.g., `ABCD1234`) + +### Example Automation + +Send an alert when a door opens: + +```yaml +automation: + - alias: "Front Door Alert" + trigger: + - platform: state + entity_id: binary_sensor.front_door + from: "off" + to: "on" + action: + - service: threema.send_message + data: + recipient: !secret my_threema_id + message: > + Front door opened at {{ now().strftime('%H:%M:%S') }} +``` + +**secrets.yaml:** +```yaml +my_threema_id: "YOURID12" +``` + +## Gateway Verification (QR Code) + +When using E2E encryption (public key configured), a **QR code image entity** is created for your gateway device. This allows identity verification: + +1. Go to **Settings** > **Devices & Services** > **Threema** +2. Click on your Gateway device +3. Find the **"Gateway QR Code"** image entity +4. Scan the QR code with the Threema app to verify the gateway's public key + +The QR code encodes `3mid:,` following the Threema verification format. This is only available when a public key is configured (E2E mode). + +## Encryption Modes + +### End-to-End (E2E) Mode +- Messages encrypted from Home Assistant to the recipient's device +- Maximum privacy - even Threema servers can't read them +- Requires private key setup + +### Basic/Simple Mode +- Messages encrypted between Home Assistant and Threema servers +- Simpler setup, no key management needed +- Still secure for most use cases + +## Key Management + +**After key generation:** +- Keys are displayed once during setup - save them immediately +- Both keys are stored in Home Assistant's config entries +- Stored in: `/config/.storage/core.config_entries` + +**Key Format:** +- Private keys start with `private:` followed by 64 hex characters +- Public keys start with `public:` followed by 64 hex characters + +**Backup:** Save your keys securely (password manager or encrypted backup). If you lose your private key, you'll need to create a new Gateway ID. + +## Reauthentication + +If your credentials become invalid, Home Assistant will prompt you to re-enter your API Secret (and optionally your private key) through the reauthentication flow. + +## Troubleshooting + +### "Cannot Connect" Error +- Verify Gateway ID starts with `*` and is exactly 8 characters +- Check API Secret is correct +- Ensure you have remaining message credits +- Test credentials at gateway.threema.ch + +### Messages Not Delivered +- Check you have credits: gateway.threema.ch > Account > Credits +- Verify recipient Threema ID is correct (8 characters) +- Check logs: Settings > System > Logs (search "threema") + +### Enable Debug Logging + +Add to `configuration.yaml`: +```yaml +logger: + default: info + logs: + homeassistant.components.threema: debug +``` + +## Security Best Practices + +1. Store recipient IDs in `secrets.yaml`, not directly in automations +2. Backup your private key securely (if using E2E mode) +3. Monitor credit usage to detect abuse +4. Use E2E mode when possible for maximum privacy + +## FAQ + +**Q: Can I receive messages in Home Assistant?** +A: Not currently. This integration is send-only. + +**Q: How much does it cost?** +A: A small amount per message. Check gateway.threema.ch for current pricing. + +**Q: What's the message length limit?** +A: 3,500 characters. + +**Q: Can I send images or files?** +A: Not yet. Text messages only in the current version. + +## Roadmap + +### Planned Features +- **Incoming messages via Gateway callbacks** — receive messages sent to your Gateway ID as HA events/triggers for automations (requires a publicly reachable URL, e.g. via Nabu Casa or Cloudflare tunnel) +- Image and file support +- Remaining credits sensor +- Rich media (video, audio, location sharing) +- Group messaging +- Message templates + +## Support + +- **Threema Gateway Docs:** https://gateway.threema.ch/en +- **HA Community:** https://community.home-assistant.io/ +- **Issues:** Report via GitHub + +## License + +This integration uses the official Threema Gateway Python SDK. +- Home Assistant: Apache License 2.0 +- Threema Gateway SDK: MIT License diff --git a/homeassistant/components/threema/__init__.py b/homeassistant/components/threema/__init__.py new file mode 100644 index 0000000000000..84de66aacc012 --- /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 0000000000000..bf11640f8a1cc --- /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 0000000000000..794f434be4bfb --- /dev/null +++ b/homeassistant/components/threema/config_flow.py @@ -0,0 +1,214 @@ +"""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] + + 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() + + client = ThreemaAPIClient( + self.hass, + gateway_id=reauth_entry.data[CONF_GATEWAY_ID], + api_secret=user_input[CONF_API_SECRET], + private_key=user_input.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 user_input.get(CONF_PRIVATE_KEY): + data_updates[CONF_PRIVATE_KEY] = user_input[CONF_PRIVATE_KEY] + 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 0000000000000..9cb071c61ac0b --- /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 0000000000000..73b4c06ca735a --- /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 0000000000000..96297550b0c13 --- /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 0000000000000..b4f848272e2ab --- /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 0000000000000..b241020eebf14 --- /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 0000000000000..3a414944956a5 --- /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 0000000000000..6a93aa0c300e6 --- /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 37b23a29df34f..44eba40c8b964 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 7c2d6a13770f9..07b56d0316dd4 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 0e48a0bb8c4af..a01b31b4e84db 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 a15ac2cd57fcd..7c90aaafab7a4 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 a2df235650e00..7164ae21fd92d 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 0000000000000..a7bc4a6805f0a --- /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 0000000000000..145483df8af8c --- /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 0000000000000..32009fbebd535 --- /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 0000000000000..a07a24629bacd --- /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 0000000000000..79e37061449fd --- /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, + )