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:
@@ -2,6 +2,7 @@
|
||||
|
||||
Ermöglicht das Senden von Nachrichten an Matrix-Räume sowie das
|
||||
Stellen von Fragen mit Antwortwartezeit (Text oder Emoji-Reaktion).
|
||||
Unterstützt mehrere Accounts (z. B. für mautrix-Bridges).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -20,6 +21,7 @@ from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from .config_flow import _effective_rooms, _effective_sync
|
||||
from .const import (
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_ACCOUNT_LABEL,
|
||||
CONF_DEVICE_ID,
|
||||
CONF_HOMESERVER,
|
||||
CONF_USERNAME,
|
||||
@@ -78,7 +80,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
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)
|
||||
try:
|
||||
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)
|
||||
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:
|
||||
all_services.extend(data.room_service_names)
|
||||
for service_name in all_services:
|
||||
if hass.services.has_service(DOMAIN, service_name):
|
||||
hass.services.async_remove(DOMAIN, service_name)
|
||||
|
||||
# Remove injected descriptions from cache so stale entries don't linger
|
||||
try:
|
||||
from homeassistant.loader import SERVICE_DESCRIPTION_CACHE as _cache_key
|
||||
except ImportError:
|
||||
_cache_key = "service_description_cache"
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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
|
||||
# ------------------------------------------------------------------
|
||||
@@ -149,15 +179,16 @@ async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> Non
|
||||
|
||||
def _inject_service_descriptions(
|
||||
hass: HomeAssistant,
|
||||
label: str,
|
||||
rooms: dict[str, str],
|
||||
room_service_names: list[str],
|
||||
display_names: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Inject dynamic service descriptions so HA shows friendly dropdowns and text fields."""
|
||||
"""Inject dynamic service descriptions into HA's cache."""
|
||||
try:
|
||||
from homeassistant.loader import SERVICE_DESCRIPTION_CACHE as _cache_key
|
||||
except ImportError:
|
||||
_cache_key = "service_description_cache" # legacy fallback
|
||||
_cache_key = "service_description_cache"
|
||||
|
||||
cache: dict = hass.data.setdefault(_cache_key, {})
|
||||
labels = display_names or {}
|
||||
@@ -168,6 +199,33 @@ def _inject_service_descriptions(
|
||||
"required": 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 = [
|
||||
{"value": rid, "label": labels.get(rid) or stored or rid}
|
||||
for rid, stored in rooms.items()
|
||||
@@ -179,13 +237,13 @@ def _inject_service_descriptions(
|
||||
"selector": {"select": {"options": room_options, "mode": "dropdown"}},
|
||||
}
|
||||
|
||||
cache[(DOMAIN, "send_message")] = {
|
||||
"name": "Matrix-Nachricht senden",
|
||||
cache[(DOMAIN, f"{label}_send_message")] = {
|
||||
"name": f"Matrix-Nachricht senden [{label}]",
|
||||
"description": "Sendet eine Textnachricht an einen konfigurierten Matrix-Raum.",
|
||||
"fields": {"room_id": room_field, "message": msg_field},
|
||||
}
|
||||
cache[(DOMAIN, "ask_question")] = {
|
||||
"name": "Frage in Matrix-Raum stellen",
|
||||
cache[(DOMAIN, f"{label}_ask_question")] = {
|
||||
"name": f"Frage in Matrix-Raum stellen [{label}]",
|
||||
"description": (
|
||||
"Sendet eine Frage und wartet auf Antwort (Text oder Emoji-Reaktion). "
|
||||
"Löst das Event 'matrix_messenger_response' aus."
|
||||
@@ -217,67 +275,28 @@ def _inject_service_descriptions(
|
||||
}
|
||||
|
||||
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(
|
||||
(rid for rid, name in rooms.items() if _room_slug(name) == slug),
|
||||
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)] = {
|
||||
"name": f"Matrix → {label}",
|
||||
"description": f'Sendet eine Nachricht an den Matrix-Raum "{label}".',
|
||||
"name": f"Matrix → {room_label} [{label}]",
|
||||
"description": f'Sendet eine Nachricht an den Matrix-Raum "{room_label}" ({label}).',
|
||||
"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(
|
||||
hass: HomeAssistant, entry: ConfigEntry, data: MatrixEntryData
|
||||
) -> None:
|
||||
label = _effective_label(entry)
|
||||
rooms = _effective_rooms(entry)
|
||||
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:
|
||||
user_id: str = call.data["user_id"]
|
||||
message: str = call.data["message"]
|
||||
@@ -287,35 +306,7 @@ def _register_services(
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"send_message",
|
||||
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",
|
||||
f"{label}_send_to_user",
|
||||
handle_send_to_user,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
@@ -325,37 +316,102 @@ def _register_services(
|
||||
),
|
||||
)
|
||||
|
||||
# Per-room convenience services: matrix_messenger.send_to_<slug>
|
||||
# (registered first so room_service_names is populated before injection)
|
||||
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)
|
||||
# Raum-basierte Services nur wenn Räume konfiguriert sind
|
||||
if room_ids:
|
||||
room_validator = vol.In(room_ids)
|
||||
|
||||
service_name = f"send_to_{slug}"
|
||||
data.room_service_names.append(service_name)
|
||||
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)
|
||||
|
||||
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
|
||||
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}",
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
service_name,
|
||||
_make_handler(room_id, room_name),
|
||||
schema=vol.Schema({vol.Required("message"): str}),
|
||||
f"{label}_send_message",
|
||||
handle_send_message,
|
||||
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,
|
||||
stop_when_idle: bool,
|
||||
) -> None:
|
||||
"""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.
|
||||
"""
|
||||
"""Poll Matrix every DEFAULT_SYNC_INTERVAL seconds."""
|
||||
while True:
|
||||
try:
|
||||
await data.client.async_sync_once(timeout_ms=5000)
|
||||
|
||||
# Expire old questions
|
||||
now = time.monotonic()
|
||||
expired = [qid for qid, q in data.pending_questions.items() if now > q.expires_at]
|
||||
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
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
@@ -12,6 +13,7 @@ from .const import (
|
||||
AUTH_METHOD_PASSWORD,
|
||||
AUTH_METHOD_TOKEN,
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_ACCOUNT_LABEL,
|
||||
CONF_AUTH_METHOD,
|
||||
CONF_DEVICE_ID,
|
||||
CONF_DEVICE_NAME,
|
||||
@@ -27,9 +29,11 @@ from .matrix_client import MatrixClient, MatrixClientError
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_LABEL_RE = re.compile(r'^[a-z0-9][a-z0-9_]{0,29}$')
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -38,21 +42,29 @@ class MatrixMessengerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
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):
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
self._data.update(user_input)
|
||||
if user_input[CONF_AUTH_METHOD] == AUTH_METHOD_PASSWORD:
|
||||
return await self.async_step_credentials_password()
|
||||
return await self.async_step_credentials_token()
|
||||
label = user_input.get(CONF_ACCOUNT_LABEL, "").strip().lower()
|
||||
if not _LABEL_RE.match(label):
|
||||
errors[CONF_ACCOUNT_LABEL] = "invalid_label"
|
||||
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(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ACCOUNT_LABEL): selector.TextSelector(
|
||||
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
|
||||
),
|
||||
vol.Required(CONF_HOMESERVER): selector.TextSelector(
|
||||
selector.TextSelectorConfig(type=selector.TextSelectorType.URL)
|
||||
),
|
||||
@@ -152,7 +164,6 @@ class MatrixMessengerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
try:
|
||||
await client.async_setup()
|
||||
# Fetch device_id from server using the provided token
|
||||
_, device_id = await client.async_whoami_device_id(
|
||||
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):
|
||||
errors: dict[str, str] = {}
|
||||
has_rooms = bool(self._available_rooms)
|
||||
|
||||
if user_input is not None:
|
||||
selected = user_input.get(CONF_ROOMS, [])
|
||||
manual_raw = user_input.get("manual_room_ids", "").strip()
|
||||
@@ -219,16 +227,12 @@ class MatrixMessengerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if rid:
|
||||
rooms[rid] = rid
|
||||
|
||||
if not rooms:
|
||||
errors["base"] = "no_rooms"
|
||||
else:
|
||||
self._data[CONF_ROOMS] = rooms
|
||||
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,
|
||||
)
|
||||
self._data[CONF_ROOMS] = rooms
|
||||
self._data[CONF_ENABLE_SYNC] = user_input.get(CONF_ENABLE_SYNC, False)
|
||||
label = self._data.get(CONF_ACCOUNT_LABEL) or self._data.get(CONF_USERNAME, self._data[CONF_HOMESERVER])
|
||||
return self.async_create_entry(title=label, data=self._data)
|
||||
|
||||
has_rooms = bool(self._available_rooms)
|
||||
schema_dict: dict = {}
|
||||
|
||||
if has_rooms:
|
||||
@@ -236,35 +240,28 @@ class MatrixMessengerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
selector.SelectOptionDict(value=rid, label=name)
|
||||
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(
|
||||
options=room_options,
|
||||
multiple=True,
|
||||
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()
|
||||
|
||||
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(
|
||||
step_id="rooms",
|
||||
data_schema=vol.Schema(schema_dict),
|
||||
description_placeholders=placeholders if placeholders else None,
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"hint": (
|
||||
"Räume sind optional. Bridge-Konten (WhatsApp, Signal, Telegram) "
|
||||
"benötigen keine Räume – dort genügt der Service 'Direktnachricht senden'."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@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, {})
|
||||
entry_data = domain_data.get(self._entry.entry_id)
|
||||
if entry_data is not None:
|
||||
@@ -325,7 +321,7 @@ class MatrixMessengerOptionsFlow(config_entries.OptionsFlow):
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ROOMS, default=current_rooms): selector.SelectSelector(
|
||||
vol.Optional(CONF_ROOMS, default=current_rooms): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=room_options,
|
||||
multiple=True,
|
||||
|
||||
@@ -10,6 +10,7 @@ CONF_DEVICE_ID = "device_id"
|
||||
CONF_DEVICE_NAME = "device_name"
|
||||
CONF_ROOMS = "rooms"
|
||||
CONF_ENABLE_SYNC = "enable_sync"
|
||||
CONF_ACCOUNT_LABEL = "account_label"
|
||||
|
||||
AUTH_METHOD_PASSWORD = "password"
|
||||
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",
|
||||
"name": "Matrix Messenger",
|
||||
"codeowners": [],
|
||||
"version": "1.2.0",
|
||||
"config_flow": true,
|
||||
"documentation": "",
|
||||
"homeassistant": "2026.4.0",
|
||||
"iot_class": "cloud_push",
|
||||
"documentation": "https://github.com/CeddysHomeAssistant/Matrix-Server",
|
||||
"issue_tracker": "https://github.com/CeddysHomeAssistant/Matrix-Server/issues",
|
||||
"codeowners": ["@CeddysHomeAssistant"],
|
||||
"requirements": ["matrix-nio[e2e]>=0.21.0"],
|
||||
"version": "1.1.0",
|
||||
"integration_type": "hub"
|
||||
"dependencies": [],
|
||||
"iot_class": "cloud_push",
|
||||
"integration_type": "hub",
|
||||
"homeassistant": "2026.4.0",
|
||||
"icon": "mdi:matrix"
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"title": "Matrix-Server",
|
||||
"description": "Verbinde Home Assistant mit deinem Matrix-Homeserver.",
|
||||
"data": {
|
||||
"account_label": "Account-Label (z. B. 'marc' oder 'ha_chaos')",
|
||||
"homeserver": "Homeserver-URL",
|
||||
"auth_method": "Anmeldeverfahren"
|
||||
},
|
||||
"data_description": {
|
||||
"account_label": "Kurzname für diesen Account. Wird Präfix der Service-Namen (nur Kleinbuchstaben, Ziffern, Unterstriche)."
|
||||
}
|
||||
},
|
||||
"credentials_password": {
|
||||
@@ -27,15 +31,17 @@
|
||||
},
|
||||
"rooms": {
|
||||
"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": {
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"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."
|
||||
},
|
||||
"abort": {
|
||||
@@ -47,7 +53,7 @@
|
||||
"init": {
|
||||
"title": "Matrix Messenger – Optionen",
|
||||
"data": {
|
||||
"rooms": "Räume",
|
||||
"rooms": "Räume (optional)",
|
||||
"enable_sync": "Hintergrund-Sync aktivieren"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"title": "Matrix-Server",
|
||||
"description": "Verbinde Home Assistant mit deinem Matrix-Homeserver.",
|
||||
"data": {
|
||||
"account_label": "Account-Label (z. B. 'marc' oder 'ha_chaos')",
|
||||
"homeserver": "Homeserver-URL",
|
||||
"auth_method": "Anmeldeverfahren"
|
||||
},
|
||||
"data_description": {
|
||||
"account_label": "Kurzname für diesen Account. Wird Präfix der Service-Namen (nur Kleinbuchstaben, Ziffern, Unterstriche)."
|
||||
}
|
||||
},
|
||||
"credentials_password": {
|
||||
@@ -26,16 +30,18 @@
|
||||
}
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Matrix-Räume auswählen",
|
||||
"description": "Wähle die Räume aus, in die Nachrichten gesendet werden sollen.",
|
||||
"title": "Matrix-Räume",
|
||||
"description": "Räume sind optional. Bridge-Konten (WhatsApp, Signal, Telegram) benötigen keine Räume – dort genügt der Service 'Direktnachricht senden'.",
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"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."
|
||||
},
|
||||
"abort": {
|
||||
@@ -47,7 +53,7 @@
|
||||
"init": {
|
||||
"title": "Matrix Messenger – Optionen",
|
||||
"data": {
|
||||
"rooms": "Räume",
|
||||
"rooms": "Räume (optional)",
|
||||
"enable_sync": "Hintergrund-Sync aktivieren"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"title": "Matrix Server",
|
||||
"description": "Connect Home Assistant to your Matrix homeserver.",
|
||||
"data": {
|
||||
"account_label": "Account label (e.g. 'marc' or 'ha_chaos')",
|
||||
"homeserver": "Homeserver URL",
|
||||
"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": {
|
||||
@@ -26,16 +30,18 @@
|
||||
}
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Select Matrix Rooms",
|
||||
"description": "Choose the rooms you want to send messages to.",
|
||||
"title": "Matrix Rooms",
|
||||
"description": "Rooms are optional. Bridge accounts (WhatsApp, Signal, Telegram) do not need rooms – the 'Send direct message' service is sufficient.",
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"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."
|
||||
},
|
||||
"abort": {
|
||||
@@ -47,7 +53,7 @@
|
||||
"init": {
|
||||
"title": "Matrix Messenger – Options",
|
||||
"data": {
|
||||
"rooms": "Rooms",
|
||||
"rooms": "Rooms (optional)",
|
||||
"enable_sync": "Enable background sync"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user