-
-
Notifications
You must be signed in to change notification settings - Fork 37.2k
New: Threema Integration #165993
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
LukasQ
wants to merge
42
commits into
home-assistant:dev
Choose a base branch
from
LukasQ:feature/threema
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
New: Threema Integration #165993
Changes from 4 commits
Commits
Show all changes
42 commits
Select commit
Hold shift + click to select a range
5834ad0
Add Threema integration
LukasQ 5f50611
Merge branch 'dev' into feature/threema
LukasQ 38d0986
Update homeassistant/components/threema/strings.json
LukasQ dd4eaeb
Update homeassistant/components/threema/strings.json
LukasQ b01d3c2
Fixed Copolot issue #2 and #3
LukasQ 60092cd
Added new roadmap ideas
LukasQ 5481ad0
Moved from service to notify entity
LukasQ b03b8f0
removed ln and changed subentry tests
LukasQ eae2386
Replaced icons by standarrd ones
LukasQ bcd275f
moved qr code pub key verification to a new feature
LukasQ 8ec8b99
Updated config flow and ID > friendly name
LukasQ 8d430a8
fixed minor typo and Copilots recommendations.
LukasQ 19d94c2
normalized inputs
LukasQ 202112a
Exception handling improved
LukasQ 4c2d925
Improved exception handling further
LukasQ db2e17a
fixed versioning
LukasQ 0fda17d
Added full quality scale coverage
LukasQ 240191f
Aded back entity name
LukasQ daa9590
Fixed unneeded logging
LukasQ a5b325b
Fixed tests
LukasQ 20bc0c3
Fixed whitespace.
LukasQ d741745
ruff-ed
LukasQ af6fa62
ruff-ed 2.0
LukasQ bf15793
Propper exception catch
LukasQ ca76333
Improved UI
LukasQ 9017161
improved subentry
LukasQ 9232502
Changed UID
LukasQ b70fe86
Added new tests for codecov
LukasQ e683d7b
improved codecov
LukasQ 9b3aaa9
One push to rule them all.
LukasQ 1b376aa
Fix failing tests
LukasQ 566b9a8
stripped reauth for now
LukasQ 9854c56
Improved testing further
LukasQ 4bd3a43
full invalid journey in tests
LukasQ 783a641
closed 100% configflow codecov
LukasQ 7b87a19
corrected test
LukasQ dd133d9
deleted remote
LukasQ 21671ee
updated exceptions
LukasQ e546ae7
removed key editing in setup phase
LukasQ e403c0a
updated testing
LukasQ c088ce9
Merge branch 'dev' into feature/threema
joostlek 409c226
Fix
joostlek File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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:<gateway_id>,<public_key_hex>`) | ||
| - **`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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
LukasQ marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
LukasQ marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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: | ||
LukasQ marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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 | ||
LukasQ marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
LukasQ marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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 | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.