Skip to content
Draft
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
5834ad0
Add Threema integration
LukasQ Mar 19, 2026
5f50611
Merge branch 'dev' into feature/threema
LukasQ Mar 19, 2026
38d0986
Update homeassistant/components/threema/strings.json
LukasQ Mar 19, 2026
dd4eaeb
Update homeassistant/components/threema/strings.json
LukasQ Mar 19, 2026
b01d3c2
Fixed Copolot issue #2 and #3
LukasQ Mar 19, 2026
60092cd
Added new roadmap ideas
LukasQ Mar 19, 2026
5481ad0
Moved from service to notify entity
LukasQ Mar 19, 2026
b03b8f0
removed ln and changed subentry tests
LukasQ Mar 19, 2026
eae2386
Replaced icons by standarrd ones
LukasQ Mar 19, 2026
bcd275f
moved qr code pub key verification to a new feature
LukasQ Mar 19, 2026
8ec8b99
Updated config flow and ID > friendly name
LukasQ Mar 19, 2026
8d430a8
fixed minor typo and Copilots recommendations.
LukasQ Mar 19, 2026
19d94c2
normalized inputs
LukasQ Mar 23, 2026
202112a
Exception handling improved
LukasQ Mar 23, 2026
4c2d925
Improved exception handling further
LukasQ Mar 23, 2026
db2e17a
fixed versioning
LukasQ Mar 23, 2026
0fda17d
Added full quality scale coverage
LukasQ Mar 23, 2026
240191f
Aded back entity name
LukasQ Mar 23, 2026
daa9590
Fixed unneeded logging
LukasQ Mar 23, 2026
a5b325b
Fixed tests
LukasQ Mar 23, 2026
20bc0c3
Fixed whitespace.
LukasQ Mar 23, 2026
d741745
ruff-ed
LukasQ Mar 23, 2026
af6fa62
ruff-ed 2.0
LukasQ Mar 23, 2026
bf15793
Propper exception catch
LukasQ Mar 23, 2026
ca76333
Improved UI
LukasQ Mar 26, 2026
9017161
improved subentry
LukasQ Mar 26, 2026
9232502
Changed UID
LukasQ Mar 26, 2026
b70fe86
Added new tests for codecov
LukasQ Mar 30, 2026
e683d7b
improved codecov
LukasQ Mar 30, 2026
9b3aaa9
One push to rule them all.
LukasQ Apr 2, 2026
1b376aa
Fix failing tests
LukasQ Apr 2, 2026
566b9a8
stripped reauth for now
LukasQ Apr 2, 2026
9854c56
Improved testing further
LukasQ Apr 2, 2026
4bd3a43
full invalid journey in tests
LukasQ Apr 2, 2026
783a641
closed 100% configflow codecov
LukasQ Apr 2, 2026
7b87a19
corrected test
LukasQ Apr 2, 2026
dd133d9
deleted remote
LukasQ Apr 7, 2026
21671ee
updated exceptions
LukasQ Apr 8, 2026
e546ae7
removed key editing in setup phase
LukasQ Apr 8, 2026
e403c0a
updated testing
LukasQ Apr 8, 2026
c088ce9
Merge branch 'dev' into feature/threema
joostlek Apr 9, 2026
409c226
Fix
joostlek Apr 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,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.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

60 changes: 60 additions & 0 deletions homeassistant/components/threema/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""The Threema Gateway integration."""

from __future__ import annotations

import logging

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady

from .client import ThreemaAPIClient, ThreemaAuthError, ThreemaConnectionError
from .const import CONF_API_SECRET, CONF_GATEWAY_ID, CONF_PRIVATE_KEY, DOMAIN

_LOGGER = logging.getLogger(__name__)

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

type ThreemaConfigEntry = ConfigEntry[ThreemaAPIClient]


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)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
return True


async def _async_update_listener(
hass: HomeAssistant, entry: ThreemaConfigEntry
) -> None:
"""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:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
133 changes: 133 additions & 0 deletions homeassistant/components/threema/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""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

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.
"""
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()
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]:
"""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
Loading
Loading