diff --git a/custom_components/matrix_messenger/__init__.py b/custom_components/matrix_messenger/__init__.py index 2f64228..5935086 100644 --- a/custom_components/matrix_messenger/__init__.py +++ b/custom_components/matrix_messenger/__init__.py @@ -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_ - # (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: diff --git a/custom_components/matrix_messenger/brand/dark_icon.png b/custom_components/matrix_messenger/brand/dark_icon.png new file mode 100644 index 0000000..da5ad18 Binary files /dev/null and b/custom_components/matrix_messenger/brand/dark_icon.png differ diff --git a/custom_components/matrix_messenger/brand/dark_icon@2x.png b/custom_components/matrix_messenger/brand/dark_icon@2x.png new file mode 100644 index 0000000..6f302da Binary files /dev/null and b/custom_components/matrix_messenger/brand/dark_icon@2x.png differ diff --git a/custom_components/matrix_messenger/brand/icon.png b/custom_components/matrix_messenger/brand/icon.png new file mode 100644 index 0000000..421e7ff Binary files /dev/null and b/custom_components/matrix_messenger/brand/icon.png differ diff --git a/custom_components/matrix_messenger/brand/icon@2x.png b/custom_components/matrix_messenger/brand/icon@2x.png new file mode 100644 index 0000000..e654c9c Binary files /dev/null and b/custom_components/matrix_messenger/brand/icon@2x.png differ diff --git a/custom_components/matrix_messenger/config_flow.py b/custom_components/matrix_messenger/config_flow.py index 4de0592..62738e8 100644 --- a/custom_components/matrix_messenger/config_flow.py +++ b/custom_components/matrix_messenger/config_flow.py @@ -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, diff --git a/custom_components/matrix_messenger/const.py b/custom_components/matrix_messenger/const.py index accf566..218dcb8 100644 --- a/custom_components/matrix_messenger/const.py +++ b/custom_components/matrix_messenger/const.py @@ -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" diff --git a/custom_components/matrix_messenger/generate_icons.py b/custom_components/matrix_messenger/generate_icons.py new file mode 100644 index 0000000..6fbb53c --- /dev/null +++ b/custom_components/matrix_messenger/generate_icons.py @@ -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.") diff --git a/custom_components/matrix_messenger/icon/icon.svg b/custom_components/matrix_messenger/icon/icon.svg new file mode 100644 index 0000000..37fb2b4 --- /dev/null +++ b/custom_components/matrix_messenger/icon/icon.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/custom_components/matrix_messenger/manifest.json b/custom_components/matrix_messenger/manifest.json index 524ff1d..03c2561 100644 --- a/custom_components/matrix_messenger/manifest.json +++ b/custom_components/matrix_messenger/manifest.json @@ -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" } diff --git a/custom_components/matrix_messenger/strings.json b/custom_components/matrix_messenger/strings.json index d16744b..bf73b45 100644 --- a/custom_components/matrix_messenger/strings.json +++ b/custom_components/matrix_messenger/strings.json @@ -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" } } diff --git a/custom_components/matrix_messenger/translations/de.json b/custom_components/matrix_messenger/translations/de.json index 1d459ac..bf73b45 100644 --- a/custom_components/matrix_messenger/translations/de.json +++ b/custom_components/matrix_messenger/translations/de.json @@ -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" } } diff --git a/custom_components/matrix_messenger/translations/en.json b/custom_components/matrix_messenger/translations/en.json index 0ae6a97..f024ec7 100644 --- a/custom_components/matrix_messenger/translations/en.json +++ b/custom_components/matrix_messenger/translations/en.json @@ -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" } } diff --git a/hacs.json b/hacs.json index 90867ef..2115669 100644 --- a/hacs.json +++ b/hacs.json @@ -1,6 +1,5 @@ { "name": "Matrix Messenger", "content_in_root": false, - "render_readme": true, - "homeassistant": "2026.4.0" + "render_readme": true }