v1.2.0: HACS-Kompatibilität, App-Icon, bereinigte Konfiguration

- manifest.json: codeowners, documentation, issue_tracker, mdi:matrix icon
- hacs.json: homeassistant-Feld entfernt (gehört in manifest.json)
- brand/: icon.png, icon@2x.png, dark_icon.png, dark_icon@2x.png hinzugefügt
- icon/icon.svg: Matrix-Chat SVG-Icon hinzugefügt
- generate_icons.py: PNG-Generator für brand/-Verzeichnis

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ceddy
2026-06-03 00:30:56 +02:00
parent 7196e334a2
commit 39dbbe5eee
14 changed files with 390 additions and 173 deletions
+166 -115
View File
@@ -2,6 +2,7 @@
Ermöglicht das Senden von Nachrichten an Matrix-Räume sowie das Ermöglicht das Senden von Nachrichten an Matrix-Räume sowie das
Stellen von Fragen mit Antwortwartezeit (Text oder Emoji-Reaktion). Stellen von Fragen mit Antwortwartezeit (Text oder Emoji-Reaktion).
Unterstützt mehrere Accounts (z. B. für mautrix-Bridges).
""" """
from __future__ import annotations from __future__ import annotations
@@ -20,6 +21,7 @@ from homeassistant.core import HomeAssistant, ServiceCall
from .config_flow import _effective_rooms, _effective_sync from .config_flow import _effective_rooms, _effective_sync
from .const import ( from .const import (
CONF_ACCESS_TOKEN, CONF_ACCESS_TOKEN,
CONF_ACCOUNT_LABEL,
CONF_DEVICE_ID, CONF_DEVICE_ID,
CONF_HOMESERVER, CONF_HOMESERVER,
CONF_USERNAME, CONF_USERNAME,
@@ -78,7 +80,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
device_id=entry.data.get(CONF_DEVICE_ID, ""), device_id=entry.data.get(CONF_DEVICE_ID, ""),
) )
# Fetch room display names via direct state API (independent of sync state)
stored_rooms = _effective_rooms(entry) stored_rooms = _effective_rooms(entry)
try: try:
display_names = await client.async_get_room_names(list(stored_rooms.keys())) display_names = await client.async_get_room_names(list(stored_rooms.keys()))
@@ -117,20 +118,28 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_cancel_sync(data) _cancel_sync(data)
await data.client.async_close() await data.client.async_close()
all_services = ["send_message", "ask_question", "send_to_user"] label = _effective_label(entry)
all_services = [
f"{label}_send_message",
f"{label}_ask_question",
f"{label}_send_to_user",
]
if data: if data:
all_services.extend(data.room_service_names) all_services.extend(data.room_service_names)
for service_name in all_services: for service_name in all_services:
if hass.services.has_service(DOMAIN, service_name): if hass.services.has_service(DOMAIN, service_name):
hass.services.async_remove(DOMAIN, service_name) hass.services.async_remove(DOMAIN, service_name)
# Remove injected descriptions from cache so stale entries don't linger
try: try:
from homeassistant.loader import SERVICE_DESCRIPTION_CACHE as _cache_key from homeassistant.loader import SERVICE_DESCRIPTION_CACHE as _cache_key
except ImportError: except ImportError:
_cache_key = "service_description_cache" _cache_key = "service_description_cache"
cache = hass.data.get(_cache_key, {}) cache = hass.data.get(_cache_key, {})
for svc in ["send_message", "ask_question"] + (data.room_service_names if data else []): for svc in [
f"{label}_send_message",
f"{label}_ask_question",
f"{label}_send_to_user",
] + (data.room_service_names if data else []):
cache.pop((DOMAIN, svc), None) cache.pop((DOMAIN, svc), None)
await hass.config_entries.async_unload_platforms(entry, PLATFORMS) await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -142,6 +151,27 @@ async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> Non
await hass.config_entries.async_reload(entry.entry_id) await hass.config_entries.async_reload(entry.entry_id)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _effective_label(entry: ConfigEntry) -> str:
"""Return the account label; fall back to a slug derived from the username."""
label = entry.data.get(CONF_ACCOUNT_LABEL, "").strip()
if not label:
username = entry.data.get(CONF_USERNAME, "")
label = re.sub(r"[^a-z0-9]+", "_", username.split(":")[0].lstrip("@").lower()).strip("_")
return label or "matrix"
def _room_slug(name: str) -> str:
"""Convert a room display name to a valid HA service name fragment."""
slug = name.lower()
slug = re.sub(r"[^a-z0-9]+", "_", slug).strip("_")
return slug or "room"
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Service registration # Service registration
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -149,15 +179,16 @@ async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> Non
def _inject_service_descriptions( def _inject_service_descriptions(
hass: HomeAssistant, hass: HomeAssistant,
label: str,
rooms: dict[str, str], rooms: dict[str, str],
room_service_names: list[str], room_service_names: list[str],
display_names: dict[str, str] | None = None, display_names: dict[str, str] | None = None,
) -> None: ) -> None:
"""Inject dynamic service descriptions so HA shows friendly dropdowns and text fields.""" """Inject dynamic service descriptions into HA's cache."""
try: try:
from homeassistant.loader import SERVICE_DESCRIPTION_CACHE as _cache_key from homeassistant.loader import SERVICE_DESCRIPTION_CACHE as _cache_key
except ImportError: except ImportError:
_cache_key = "service_description_cache" # legacy fallback _cache_key = "service_description_cache"
cache: dict = hass.data.setdefault(_cache_key, {}) cache: dict = hass.data.setdefault(_cache_key, {})
labels = display_names or {} labels = display_names or {}
@@ -168,6 +199,33 @@ def _inject_service_descriptions(
"required": True, "required": True,
"selector": {"text": {"multiline": True}}, "selector": {"text": {"multiline": True}},
} }
# send_to_user immer verfügbar (Bridge-Nutzung)
cache[(DOMAIN, f"{label}_send_to_user")] = {
"name": f"Matrix DM senden [{label}]",
"description": (
"Sendet eine Direktnachricht oder Bridge-Nachricht (WhatsApp, Signal, Telegram) "
"an eine Matrix-User-ID."
),
"fields": {
"user_id": {
"name": "Ziel-User-ID",
"description": (
"Matrix-User-ID des Empfängers, z. B. "
"@whatsapp_4917612345678:server.de oder "
"@signal_+4917612345678:server.de oder "
"@telegram_123456789:server.de"
),
"required": True,
"selector": {"text": {}},
},
"message": msg_field,
},
}
if not rooms:
return
room_options = [ room_options = [
{"value": rid, "label": labels.get(rid) or stored or rid} {"value": rid, "label": labels.get(rid) or stored or rid}
for rid, stored in rooms.items() for rid, stored in rooms.items()
@@ -179,13 +237,13 @@ def _inject_service_descriptions(
"selector": {"select": {"options": room_options, "mode": "dropdown"}}, "selector": {"select": {"options": room_options, "mode": "dropdown"}},
} }
cache[(DOMAIN, "send_message")] = { cache[(DOMAIN, f"{label}_send_message")] = {
"name": "Matrix-Nachricht senden", "name": f"Matrix-Nachricht senden [{label}]",
"description": "Sendet eine Textnachricht an einen konfigurierten Matrix-Raum.", "description": "Sendet eine Textnachricht an einen konfigurierten Matrix-Raum.",
"fields": {"room_id": room_field, "message": msg_field}, "fields": {"room_id": room_field, "message": msg_field},
} }
cache[(DOMAIN, "ask_question")] = { cache[(DOMAIN, f"{label}_ask_question")] = {
"name": "Frage in Matrix-Raum stellen", "name": f"Frage in Matrix-Raum stellen [{label}]",
"description": ( "description": (
"Sendet eine Frage und wartet auf Antwort (Text oder Emoji-Reaktion). " "Sendet eine Frage und wartet auf Antwort (Text oder Emoji-Reaktion). "
"Löst das Event 'matrix_messenger_response' aus." "Löst das Event 'matrix_messenger_response' aus."
@@ -217,67 +275,28 @@ def _inject_service_descriptions(
} }
for service_name in room_service_names: for service_name in room_service_names:
slug = service_name[len("send_to_"):] prefix = f"{label}_send_to_"
slug = service_name[len(prefix):]
room_id = next( room_id = next(
(rid for rid, name in rooms.items() if _room_slug(name) == slug), (rid for rid, name in rooms.items() if _room_slug(name) == slug),
None, None,
) )
label = (labels.get(room_id) or rooms.get(room_id) or slug) if room_id else slug room_label = (labels.get(room_id) or rooms.get(room_id) or slug) if room_id else slug
cache[(DOMAIN, service_name)] = { cache[(DOMAIN, service_name)] = {
"name": f"Matrix → {label}", "name": f"Matrix → {room_label} [{label}]",
"description": f'Sendet eine Nachricht an den Matrix-Raum "{label}".', "description": f'Sendet eine Nachricht an den Matrix-Raum "{room_label}" ({label}).',
"fields": {"message": msg_field}, "fields": {"message": msg_field},
} }
def _room_slug(name: str) -> str:
"""Convert a room display name to a valid HA service name fragment."""
slug = name.lower()
slug = re.sub(r"[^a-z0-9]+", "_", slug).strip("_")
return slug or "room"
def _register_services( def _register_services(
hass: HomeAssistant, entry: ConfigEntry, data: MatrixEntryData hass: HomeAssistant, entry: ConfigEntry, data: MatrixEntryData
) -> None: ) -> None:
label = _effective_label(entry)
rooms = _effective_rooms(entry) rooms = _effective_rooms(entry)
room_ids = list(rooms.keys()) room_ids = list(rooms.keys())
room_validator = vol.In(room_ids) if room_ids else str
async def handle_send_message(call: ServiceCall) -> None:
room_id: str = call.data["room_id"]
message: str = call.data["message"]
success = await data.client.async_send_message(room_id, message)
if not success:
_LOGGER.error("Nachricht an %s konnte nicht gesendet werden", room_id)
async def handle_ask_question(call: ServiceCall) -> None:
room_id: str = call.data["room_id"]
question: str = call.data["question"]
options: list[str] = call.data.get("options", [])
timeout: int = call.data.get("timeout", DEFAULT_QUESTION_TIMEOUT)
text = question
if options:
text = f"{question}\n\nMögliche Antworten: {' / '.join(options)}"
await data.client.async_send_message(room_id, text)
qid = str(uuid.uuid4())
data.pending_questions[qid] = PendingQuestion(
question_id=qid,
room_id=room_id,
options=options,
expires_at=time.monotonic() + timeout,
)
_LOGGER.debug("Frage %s wartet auf Antwort in Raum %s", qid, room_id)
if data.sync_task is None or data.sync_task.done():
data.sync_task = hass.async_create_background_task(
_sync_loop(hass, entry, data, stop_when_idle=True),
name=f"{DOMAIN}_sync_{entry.entry_id}",
)
# send_to_user immer registrieren (Bridge + native Matrix DM)
async def handle_send_to_user(call: ServiceCall) -> None: async def handle_send_to_user(call: ServiceCall) -> None:
user_id: str = call.data["user_id"] user_id: str = call.data["user_id"]
message: str = call.data["message"] message: str = call.data["message"]
@@ -287,35 +306,7 @@ def _register_services(
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,
"send_message", f"{label}_send_to_user",
handle_send_message,
schema=vol.Schema(
{
vol.Required("room_id"): room_validator,
vol.Required("message"): str,
}
),
)
hass.services.async_register(
DOMAIN,
"ask_question",
handle_ask_question,
schema=vol.Schema(
{
vol.Required("room_id"): room_validator,
vol.Required("question"): str,
vol.Optional("options", default=[]): [str],
vol.Optional("timeout", default=DEFAULT_QUESTION_TIMEOUT): vol.All(
int, vol.Range(min=60, max=7200)
),
}
),
)
hass.services.async_register(
DOMAIN,
"send_to_user",
handle_send_to_user, handle_send_to_user,
schema=vol.Schema( schema=vol.Schema(
{ {
@@ -325,37 +316,102 @@ def _register_services(
), ),
) )
# Per-room convenience services: matrix_messenger.send_to_<slug> # Raum-basierte Services nur wenn Räume konfiguriert sind
# (registered first so room_service_names is populated before injection) if room_ids:
used_slugs: set[str] = set() room_validator = vol.In(room_ids)
for room_id, room_name in rooms.items():
base = _room_slug(room_name)
slug = base
counter = 2
while slug in used_slugs:
slug = f"{base}_{counter}"
counter += 1
used_slugs.add(slug)
service_name = f"send_to_{slug}" async def handle_send_message(call: ServiceCall) -> None:
data.room_service_names.append(service_name) room_id: str = call.data["room_id"]
message: str = call.data["message"]
success = await data.client.async_send_message(room_id, message)
if not success:
_LOGGER.error("Nachricht an %s konnte nicht gesendet werden", room_id)
def _make_handler(rid: str, rname: str): async def handle_ask_question(call: ServiceCall) -> None:
async def handler(call: ServiceCall) -> None: room_id: str = call.data["room_id"]
msg: str = call.data["message"] question: str = call.data["question"]
success = await data.client.async_send_message(rid, msg) options: list[str] = call.data.get("options", [])
if not success: timeout: int = call.data.get("timeout", DEFAULT_QUESTION_TIMEOUT)
_LOGGER.error("Nachricht an %s (%s) konnte nicht gesendet werden", rname, rid)
return handler text = question
if options:
text = f"{question}\n\nMögliche Antworten: {' / '.join(options)}"
await data.client.async_send_message(room_id, text)
qid = str(uuid.uuid4())
data.pending_questions[qid] = PendingQuestion(
question_id=qid,
room_id=room_id,
options=options,
expires_at=time.monotonic() + timeout,
)
_LOGGER.debug("Frage %s wartet auf Antwort in Raum %s", qid, room_id)
if data.sync_task is None or data.sync_task.done():
data.sync_task = hass.async_create_background_task(
_sync_loop(hass, entry, data, stop_when_idle=True),
name=f"{DOMAIN}_sync_{entry.entry_id}",
)
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,
service_name, f"{label}_send_message",
_make_handler(room_id, room_name), handle_send_message,
schema=vol.Schema({vol.Required("message"): str}), schema=vol.Schema(
{
vol.Required("room_id"): room_validator,
vol.Required("message"): str,
}
),
) )
_inject_service_descriptions(hass, rooms, data.room_service_names, data.display_names) hass.services.async_register(
DOMAIN,
f"{label}_ask_question",
handle_ask_question,
schema=vol.Schema(
{
vol.Required("room_id"): room_validator,
vol.Required("question"): str,
vol.Optional("options", default=[]): [str],
vol.Optional("timeout", default=DEFAULT_QUESTION_TIMEOUT): vol.All(
int, vol.Range(min=60, max=7200)
),
}
),
)
# Per-room convenience services: matrix_messenger.{label}_send_to_{slug}
used_slugs: set[str] = set()
for room_id, room_name in rooms.items():
base = _room_slug(room_name)
slug = base
counter = 2
while slug in used_slugs:
slug = f"{base}_{counter}"
counter += 1
used_slugs.add(slug)
service_name = f"{label}_send_to_{slug}"
data.room_service_names.append(service_name)
def _make_handler(rid: str, rname: str):
async def handler(call: ServiceCall) -> None:
msg: str = call.data["message"]
success = await data.client.async_send_message(rid, msg)
if not success:
_LOGGER.error("Nachricht an %s (%s) konnte nicht gesendet werden", rname, rid)
return handler
hass.services.async_register(
DOMAIN,
service_name,
_make_handler(room_id, room_name),
schema=vol.Schema({vol.Required("message"): str}),
)
_inject_service_descriptions(hass, label, rooms, data.room_service_names, data.display_names)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -369,16 +425,11 @@ async def _sync_loop(
data: MatrixEntryData, data: MatrixEntryData,
stop_when_idle: bool, stop_when_idle: bool,
) -> None: ) -> None:
"""Poll Matrix every DEFAULT_SYNC_INTERVAL seconds. """Poll Matrix every DEFAULT_SYNC_INTERVAL seconds."""
When stop_when_idle=True, the loop exits automatically once all
pending questions have been answered or expired.
"""
while True: while True:
try: try:
await data.client.async_sync_once(timeout_ms=5000) await data.client.async_sync_once(timeout_ms=5000)
# Expire old questions
now = time.monotonic() now = time.monotonic()
expired = [qid for qid, q in data.pending_questions.items() if now > q.expires_at] expired = [qid for qid, q in data.pending_questions.items() if now > q.expires_at]
for qid in expired: for qid in expired:
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
@@ -12,6 +13,7 @@ from .const import (
AUTH_METHOD_PASSWORD, AUTH_METHOD_PASSWORD,
AUTH_METHOD_TOKEN, AUTH_METHOD_TOKEN,
CONF_ACCESS_TOKEN, CONF_ACCESS_TOKEN,
CONF_ACCOUNT_LABEL,
CONF_AUTH_METHOD, CONF_AUTH_METHOD,
CONF_DEVICE_ID, CONF_DEVICE_ID,
CONF_DEVICE_NAME, CONF_DEVICE_NAME,
@@ -27,9 +29,11 @@ from .matrix_client import MatrixClient, MatrixClientError
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_LABEL_RE = re.compile(r'^[a-z0-9][a-z0-9_]{0,29}$')
class MatrixMessengerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class MatrixMessengerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Multi-step config flow: server → credentials → room selection.""" """Multi-step config flow: label + server → credentials → room selection."""
VERSION = 1 VERSION = 1
@@ -38,21 +42,29 @@ class MatrixMessengerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._available_rooms: dict[str, str] = {} self._available_rooms: dict[str, str] = {}
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Step 1: homeserver + auth method # Step 1: account label + homeserver + auth method
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def async_step_user(self, user_input=None): async def async_step_user(self, user_input=None):
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
self._data.update(user_input) label = user_input.get(CONF_ACCOUNT_LABEL, "").strip().lower()
if user_input[CONF_AUTH_METHOD] == AUTH_METHOD_PASSWORD: if not _LABEL_RE.match(label):
return await self.async_step_credentials_password() errors[CONF_ACCOUNT_LABEL] = "invalid_label"
return await self.async_step_credentials_token() else:
self._data.update(user_input)
self._data[CONF_ACCOUNT_LABEL] = label
if user_input[CONF_AUTH_METHOD] == AUTH_METHOD_PASSWORD:
return await self.async_step_credentials_password()
return await self.async_step_credentials_token()
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
data_schema=vol.Schema( data_schema=vol.Schema(
{ {
vol.Required(CONF_ACCOUNT_LABEL): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
),
vol.Required(CONF_HOMESERVER): selector.TextSelector( vol.Required(CONF_HOMESERVER): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.URL) selector.TextSelectorConfig(type=selector.TextSelectorType.URL)
), ),
@@ -152,7 +164,6 @@ class MatrixMessengerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) )
try: try:
await client.async_setup() await client.async_setup()
# Fetch device_id from server using the provided token
_, device_id = await client.async_whoami_device_id( _, device_id = await client.async_whoami_device_id(
user_input[CONF_ACCESS_TOKEN] user_input[CONF_ACCESS_TOKEN]
) )
@@ -198,13 +209,10 @@ class MatrixMessengerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) )
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Step 3: room selection # Step 3: room selection (optional bridge-only accounts need no rooms)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def async_step_rooms(self, user_input=None): async def async_step_rooms(self, user_input=None):
errors: dict[str, str] = {}
has_rooms = bool(self._available_rooms)
if user_input is not None: if user_input is not None:
selected = user_input.get(CONF_ROOMS, []) selected = user_input.get(CONF_ROOMS, [])
manual_raw = user_input.get("manual_room_ids", "").strip() manual_raw = user_input.get("manual_room_ids", "").strip()
@@ -219,16 +227,12 @@ class MatrixMessengerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if rid: if rid:
rooms[rid] = rid rooms[rid] = rid
if not rooms: self._data[CONF_ROOMS] = rooms
errors["base"] = "no_rooms" self._data[CONF_ENABLE_SYNC] = user_input.get(CONF_ENABLE_SYNC, False)
else: label = self._data.get(CONF_ACCOUNT_LABEL) or self._data.get(CONF_USERNAME, self._data[CONF_HOMESERVER])
self._data[CONF_ROOMS] = rooms return self.async_create_entry(title=label, data=self._data)
self._data[CONF_ENABLE_SYNC] = user_input.get(CONF_ENABLE_SYNC, False)
return self.async_create_entry(
title=self._data.get(CONF_USERNAME, self._data[CONF_HOMESERVER]),
data=self._data,
)
has_rooms = bool(self._available_rooms)
schema_dict: dict = {} schema_dict: dict = {}
if has_rooms: if has_rooms:
@@ -236,35 +240,28 @@ class MatrixMessengerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
selector.SelectOptionDict(value=rid, label=name) selector.SelectOptionDict(value=rid, label=name)
for rid, name in self._available_rooms.items() for rid, name in self._available_rooms.items()
] ]
schema_dict[vol.Required(CONF_ROOMS)] = selector.SelectSelector( schema_dict[vol.Optional(CONF_ROOMS, default=[])] = selector.SelectSelector(
selector.SelectSelectorConfig( selector.SelectSelectorConfig(
options=room_options, options=room_options,
multiple=True, multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN, mode=selector.SelectSelectorMode.DROPDOWN,
) )
) )
schema_dict[vol.Optional("manual_room_ids", default="")] = selector.TextSelector(
selector.TextSelectorConfig(multiline=False)
)
else:
schema_dict[vol.Required("manual_room_ids")] = selector.TextSelector(
selector.TextSelectorConfig(multiline=True)
)
schema_dict[vol.Optional("manual_room_ids", default="")] = selector.TextSelector(
selector.TextSelectorConfig(multiline=False)
)
schema_dict[vol.Optional(CONF_ENABLE_SYNC, default=False)] = selector.BooleanSelector() schema_dict[vol.Optional(CONF_ENABLE_SYNC, default=False)] = selector.BooleanSelector()
placeholders = {}
if not has_rooms:
placeholders["hint"] = (
"Keine Räume gefunden. Raum-IDs eingeben (z.B. !abc123:chat.example.com), "
"mehrere mit Komma oder Leerzeichen trennen."
)
return self.async_show_form( return self.async_show_form(
step_id="rooms", step_id="rooms",
data_schema=vol.Schema(schema_dict), data_schema=vol.Schema(schema_dict),
description_placeholders=placeholders if placeholders else None, description_placeholders={
errors=errors, "hint": (
"Räume sind optional. Bridge-Konten (WhatsApp, Signal, Telegram) "
"benötigen keine Räume dort genügt der Service 'Direktnachricht senden'."
)
},
) )
@staticmethod @staticmethod
@@ -302,7 +299,6 @@ class MatrixMessengerOptionsFlow(config_entries.OptionsFlow):
}, },
) )
# Try to load fresh room list from the running client
domain_data = self.hass.data.get(DOMAIN, {}) domain_data = self.hass.data.get(DOMAIN, {})
entry_data = domain_data.get(self._entry.entry_id) entry_data = domain_data.get(self._entry.entry_id)
if entry_data is not None: if entry_data is not None:
@@ -325,7 +321,7 @@ class MatrixMessengerOptionsFlow(config_entries.OptionsFlow):
step_id="init", step_id="init",
data_schema=vol.Schema( data_schema=vol.Schema(
{ {
vol.Required(CONF_ROOMS, default=current_rooms): selector.SelectSelector( vol.Optional(CONF_ROOMS, default=current_rooms): selector.SelectSelector(
selector.SelectSelectorConfig( selector.SelectSelectorConfig(
options=room_options, options=room_options,
multiple=True, multiple=True,
@@ -10,6 +10,7 @@ CONF_DEVICE_ID = "device_id"
CONF_DEVICE_NAME = "device_name" CONF_DEVICE_NAME = "device_name"
CONF_ROOMS = "rooms" CONF_ROOMS = "rooms"
CONF_ENABLE_SYNC = "enable_sync" CONF_ENABLE_SYNC = "enable_sync"
CONF_ACCOUNT_LABEL = "account_label"
AUTH_METHOD_PASSWORD = "password" AUTH_METHOD_PASSWORD = "password"
AUTH_METHOD_TOKEN = "token" AUTH_METHOD_TOKEN = "token"
@@ -0,0 +1,116 @@
"""
Generate icon PNGs for the matrix_messenger integration.
Run once: python generate_icons.py
Requires: pip install Pillow
Output: icon.png, icon@2x.png, dark_icon.png, dark_icon@2x.png
"""
from pathlib import Path
try:
from PIL import Image, ImageDraw
except ImportError:
raise SystemExit("Pillow not found. Install it with: pip install Pillow")
NAVY = (26, 26, 46, 255) # #1A1A2E (background)
PURPLE = (91, 45, 142, 255) # #5B2D8E (speech bubble)
TEAL = ( 3, 218, 198, 255) # #03DAC6 (badge)
WHITE = (255, 255, 255, 255)
TRANSPARENT = (0, 0, 0, 0)
DARK_BG = (18, 18, 18, 255) # near-black for dark-mode variant
def draw_icon(size: int, dark: bool) -> Image.Image:
s = size / 256
img = Image.new("RGBA", (size, size), TRANSPARENT)
d = ImageDraw.Draw(img)
bg = DARK_BG if dark else NAVY
# ── Outer rounded background ────────────────────────────────────────────
d.rounded_rectangle([0, 0, size - 1, size - 1], radius=int(48 * s), fill=bg)
# ── Speech bubble ───────────────────────────────────────────────────────
# Body (rounded rect, top + sides)
bx0, by0 = int(36 * s), int(36 * s)
bx1, by1 = int(220 * s), int(168 * s)
d.rounded_rectangle([bx0, by0, bx1, by1], radius=int(16 * s), fill=PURPLE)
# Tail triangle pointing down-center
cx = int(128 * s)
ty = int(168 * s)
tw = int(28 * s)
th = int(34 * s)
d.polygon(
[(cx - tw, ty), (cx + tw, ty), (cx, ty + th)],
fill=PURPLE,
)
# ── Left bracket [ ──────────────────────────────────────────────────────
lx = int(60 * s)
bw = int(11 * s)
bh = int(60 * s)
cap = int(26 * s)
cr = int(3 * s)
by_top = int(78 * s)
by_bot = int(127 * s)
d.rounded_rectangle([lx, by_top, lx + bw, by_top + bh], radius=cr, fill=WHITE) # vertical
d.rounded_rectangle([lx, by_top, lx + cap, by_top + bw], radius=cr, fill=WHITE) # top cap
d.rounded_rectangle([lx, by_bot, lx + cap, by_bot + bw], radius=cr, fill=WHITE) # bottom cap
# ── Right bracket ] ─────────────────────────────────────────────────────
rx = int(185 * s)
d.rounded_rectangle([rx, by_top, rx + bw, by_top + bh], radius=cr, fill=WHITE)
d.rounded_rectangle([rx - cap + bw, by_top, rx + bw, by_top + bw], radius=cr, fill=WHITE)
d.rounded_rectangle([rx - cap + bw, by_bot, rx + bw, by_bot + bw], radius=cr, fill=WHITE)
# ── M letter (polyline: left-up, peak, right-up, right-down) ────────────
sw = int(12 * s)
pts = [
(int(97 * s), int(132 * s)),
(int(97 * s), int(84 * s)),
(int(128 * s), int(114 * s)),
(int(159 * s), int(84 * s)),
(int(159 * s), int(132 * s)),
]
for i in range(len(pts) - 1):
x0, y0 = pts[i]
x1, y1 = pts[i + 1]
d.line([x0, y0, x1, y1], fill=WHITE, width=sw)
# round caps
for px, py in pts:
r = sw // 2
d.ellipse([px - r, py - r, px + r, py + r], fill=WHITE)
# ── Teal badge with three dots ───────────────────────────────────────────
bc = int(196 * s)
br = int(36 * s)
d.ellipse([bc - br, bc - br, bc + br, bc + br], fill=TEAL)
dot_r = int(7 * s)
for dx in (-12, 0, 12):
cx2 = bc + int(dx * s)
d.ellipse([cx2 - dot_r, bc - dot_r, cx2 + dot_r, bc + dot_r], fill=bg)
return img
HERE = Path(__file__).parent / "brand"
HERE.mkdir(exist_ok=True)
variants = [
("icon.png", 256, False),
("icon@2x.png", 512, False),
("dark_icon.png", 256, True),
("dark_icon@2x.png", 512, True),
]
for filename, size, dark in variants:
path = HERE / filename
draw_icon(size, dark).save(path, "PNG")
print(f" created brand/{path.name} ({size}×{size})")
print("\nDone. Restart Home Assistant to pick up the new icons.")
@@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<!-- Background -->
<rect width="256" height="256" rx="48" ry="48" fill="#1A1A2E"/>
<!-- Speech bubble body -->
<path d="M36,52 Q36,36 52,36 H204 Q220,36 220,52 V152 Q220,168 204,168 H152 L128,200 L104,168 H52 Q36,168 36,152 Z" fill="#5B2D8E"/>
<!-- Left bracket [ -->
<rect x="60" y="78" width="11" height="60" rx="3" fill="#FFFFFF"/>
<rect x="60" y="78" width="26" height="11" rx="3" fill="#FFFFFF"/>
<rect x="60" y="127" width="26" height="11" rx="3" fill="#FFFFFF"/>
<!-- Right bracket ] -->
<rect x="185" y="78" width="11" height="60" rx="3" fill="#FFFFFF"/>
<rect x="170" y="78" width="26" height="11" rx="3" fill="#FFFFFF"/>
<rect x="170" y="127" width="26" height="11" rx="3" fill="#FFFFFF"/>
<!-- M letter -->
<polyline
points="97,132 97,84 128,114 159,84 159,132"
fill="none"
stroke="#FFFFFF"
stroke-width="12"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Signal dots (communication) bottom-right badge -->
<circle cx="196" cy="196" r="36" fill="#03DAC6"/>
<circle cx="184" cy="196" r="7" fill="#1A1A2E"/>
<circle cx="196" cy="196" r="7" fill="#1A1A2E"/>
<circle cx="208" cy="196" r="7" fill="#1A1A2E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1,12 +1,15 @@
{ {
"domain": "matrix_messenger", "domain": "matrix_messenger",
"name": "Matrix Messenger", "name": "Matrix Messenger",
"codeowners": [], "version": "1.2.0",
"config_flow": true, "config_flow": true,
"documentation": "", "documentation": "https://github.com/CeddysHomeAssistant/Matrix-Server",
"homeassistant": "2026.4.0", "issue_tracker": "https://github.com/CeddysHomeAssistant/Matrix-Server/issues",
"iot_class": "cloud_push", "codeowners": ["@CeddysHomeAssistant"],
"requirements": ["matrix-nio[e2e]>=0.21.0"], "requirements": ["matrix-nio[e2e]>=0.21.0"],
"version": "1.1.0", "dependencies": [],
"integration_type": "hub" "iot_class": "cloud_push",
"integration_type": "hub",
"homeassistant": "2026.4.0",
"icon": "mdi:matrix"
} }
@@ -5,8 +5,12 @@
"title": "Matrix-Server", "title": "Matrix-Server",
"description": "Verbinde Home Assistant mit deinem Matrix-Homeserver.", "description": "Verbinde Home Assistant mit deinem Matrix-Homeserver.",
"data": { "data": {
"account_label": "Account-Label (z. B. 'marc' oder 'ha_chaos')",
"homeserver": "Homeserver-URL", "homeserver": "Homeserver-URL",
"auth_method": "Anmeldeverfahren" "auth_method": "Anmeldeverfahren"
},
"data_description": {
"account_label": "Kurzname für diesen Account. Wird Präfix der Service-Namen (nur Kleinbuchstaben, Ziffern, Unterstriche)."
} }
}, },
"credentials_password": { "credentials_password": {
@@ -27,15 +31,17 @@
}, },
"rooms": { "rooms": {
"title": "Matrix-Räume", "title": "Matrix-Räume",
"description": "Wähle die Räume aus, in die Nachrichten gesendet werden sollen.", "description": "Räume sind optional. Bridge-Konten (WhatsApp, Signal, Telegram) benötigen keine Räume dort genügt der Service 'Direktnachricht senden'.",
"data": { "data": {
"rooms": "Räume", "rooms": "Räume (optional)",
"manual_room_ids": "Zusätzliche Raum-IDs (komma- oder leerzeichengetrennt)",
"enable_sync": "Hintergrund-Sync aktivieren (für Antwortempfang)" "enable_sync": "Hintergrund-Sync aktivieren (für Antwortempfang)"
} }
} }
}, },
"error": { "error": {
"cannot_connect": "Verbindung zum Matrix-Server fehlgeschlagen. Bitte URL und Zugangsdaten prüfen.", "cannot_connect": "Verbindung zum Matrix-Server fehlgeschlagen. Bitte URL und Zugangsdaten prüfen.",
"invalid_label": "Das Label darf nur Kleinbuchstaben, Ziffern und Unterstriche enthalten und muss mit einem Buchstaben oder einer Ziffer beginnen (z. B. 'marc').",
"unknown": "Ein unerwarteter Fehler ist aufgetreten." "unknown": "Ein unerwarteter Fehler ist aufgetreten."
}, },
"abort": { "abort": {
@@ -47,7 +53,7 @@
"init": { "init": {
"title": "Matrix Messenger Optionen", "title": "Matrix Messenger Optionen",
"data": { "data": {
"rooms": "Räume", "rooms": "Räume (optional)",
"enable_sync": "Hintergrund-Sync aktivieren" "enable_sync": "Hintergrund-Sync aktivieren"
} }
} }
@@ -5,8 +5,12 @@
"title": "Matrix-Server", "title": "Matrix-Server",
"description": "Verbinde Home Assistant mit deinem Matrix-Homeserver.", "description": "Verbinde Home Assistant mit deinem Matrix-Homeserver.",
"data": { "data": {
"account_label": "Account-Label (z. B. 'marc' oder 'ha_chaos')",
"homeserver": "Homeserver-URL", "homeserver": "Homeserver-URL",
"auth_method": "Anmeldeverfahren" "auth_method": "Anmeldeverfahren"
},
"data_description": {
"account_label": "Kurzname für diesen Account. Wird Präfix der Service-Namen (nur Kleinbuchstaben, Ziffern, Unterstriche)."
} }
}, },
"credentials_password": { "credentials_password": {
@@ -26,16 +30,18 @@
} }
}, },
"rooms": { "rooms": {
"title": "Matrix-Räume auswählen", "title": "Matrix-Räume",
"description": "Wähle die Räume aus, in die Nachrichten gesendet werden sollen.", "description": "Räume sind optional. Bridge-Konten (WhatsApp, Signal, Telegram) benötigen keine Räume dort genügt der Service 'Direktnachricht senden'.",
"data": { "data": {
"rooms": "Räume", "rooms": "Räume (optional)",
"manual_room_ids": "Zusätzliche Raum-IDs (komma- oder leerzeichengetrennt)",
"enable_sync": "Hintergrund-Sync aktivieren (für Antwortempfang)" "enable_sync": "Hintergrund-Sync aktivieren (für Antwortempfang)"
} }
} }
}, },
"error": { "error": {
"cannot_connect": "Verbindung zum Matrix-Server fehlgeschlagen. Bitte URL und Zugangsdaten prüfen.", "cannot_connect": "Verbindung zum Matrix-Server fehlgeschlagen. Bitte URL und Zugangsdaten prüfen.",
"invalid_label": "Das Label darf nur Kleinbuchstaben, Ziffern und Unterstriche enthalten und muss mit einem Buchstaben oder einer Ziffer beginnen (z. B. 'marc').",
"unknown": "Ein unerwarteter Fehler ist aufgetreten." "unknown": "Ein unerwarteter Fehler ist aufgetreten."
}, },
"abort": { "abort": {
@@ -47,7 +53,7 @@
"init": { "init": {
"title": "Matrix Messenger Optionen", "title": "Matrix Messenger Optionen",
"data": { "data": {
"rooms": "Räume", "rooms": "Räume (optional)",
"enable_sync": "Hintergrund-Sync aktivieren" "enable_sync": "Hintergrund-Sync aktivieren"
} }
} }
@@ -5,8 +5,12 @@
"title": "Matrix Server", "title": "Matrix Server",
"description": "Connect Home Assistant to your Matrix homeserver.", "description": "Connect Home Assistant to your Matrix homeserver.",
"data": { "data": {
"account_label": "Account label (e.g. 'marc' or 'ha_chaos')",
"homeserver": "Homeserver URL", "homeserver": "Homeserver URL",
"auth_method": "Authentication method" "auth_method": "Authentication method"
},
"data_description": {
"account_label": "Short name for this account. Used as prefix in service names (lowercase letters, digits, underscores only)."
} }
}, },
"credentials_password": { "credentials_password": {
@@ -26,16 +30,18 @@
} }
}, },
"rooms": { "rooms": {
"title": "Select Matrix Rooms", "title": "Matrix Rooms",
"description": "Choose the rooms you want to send messages to.", "description": "Rooms are optional. Bridge accounts (WhatsApp, Signal, Telegram) do not need rooms the 'Send direct message' service is sufficient.",
"data": { "data": {
"rooms": "Rooms", "rooms": "Rooms (optional)",
"manual_room_ids": "Additional room IDs (comma- or space-separated)",
"enable_sync": "Enable background sync (required to receive replies)" "enable_sync": "Enable background sync (required to receive replies)"
} }
} }
}, },
"error": { "error": {
"cannot_connect": "Could not connect to the Matrix server. Please check the URL and credentials.", "cannot_connect": "Could not connect to the Matrix server. Please check the URL and credentials.",
"invalid_label": "The label may only contain lowercase letters, digits, and underscores, and must start with a letter or digit (e.g. 'marc').",
"unknown": "An unexpected error occurred." "unknown": "An unexpected error occurred."
}, },
"abort": { "abort": {
@@ -47,7 +53,7 @@
"init": { "init": {
"title": "Matrix Messenger Options", "title": "Matrix Messenger Options",
"data": { "data": {
"rooms": "Rooms", "rooms": "Rooms (optional)",
"enable_sync": "Enable background sync" "enable_sync": "Enable background sync"
} }
} }
+1 -2
View File
@@ -1,6 +1,5 @@
{ {
"name": "Matrix Messenger", "name": "Matrix Messenger",
"content_in_root": false, "content_in_root": false,
"render_readme": true, "render_readme": true
"homeassistant": "2026.4.0"
} }