From 82cb0524ccfdf3c570586c2826b113c5498912c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Bachmann?= Date: Tue, 4 Aug 2026 14:09:59 +0200 Subject: [PATCH] Fix add-on discovery and bridge communication --- CHANGELOG.md | 7 +- .../whatsapp_bridge_integration/__init__.py | 11 ++- .../whatsapp_bridge_integration/api.py | 93 +++++++++++++++++++ .../whatsapp_bridge_integration/manifest.json | 4 +- .../whatsapp_bridge_integration/sensor.py | 32 +++---- .../whatsapp_bridge_integration/services.py | 21 +++-- 6 files changed, 138 insertions(+), 30 deletions(-) create mode 100644 custom_components/whatsapp_bridge_integration/api.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b1944fb..c25d880 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # CHANGELOG - Whatsapp Bridge Integration +## Release 0.3.0 - 2026-08-04 +- **Fix:** Discover the installed WhatsApp Bridge add-on through the Supervisor API instead of relying exclusively on the old hard-coded repository hostname. +- **Fix:** Reuse Home Assistant's asynchronous HTTP session for status checks and message delivery. +- **Fix:** Surface HTTP and add-on errors when sending a message instead of reporting failed requests as successful. + ## Release 0.2.4 - 2026-06-15 - **Fix:** Resolved a `TypeError` in `async_get_or_create()` caused by stricter Home Assistant Core requirements demanding a `config_entry_id` for all device registry entries. - **Architecture Refinement:** The global "WhatsApp Bridge Hub" now utilizes a shared multi-entry architecture. It dynamically links to active configurations under a static identifier, ensuring the hub remains a single, non-duplicated entity that survives individual account deletions. @@ -25,4 +30,4 @@ - **Improved:** The sensor is assigned to the new main device, which significantly simplifies automations (e.g., alert on connection loss). ## Release 0.1.7 -- Previous stable version \ No newline at end of file +- Previous stable version diff --git a/custom_components/whatsapp_bridge_integration/__init__.py b/custom_components/whatsapp_bridge_integration/__init__.py index bd279dd..a788d3c 100644 --- a/custom_components/whatsapp_bridge_integration/__init__.py +++ b/custom_components/whatsapp_bridge_integration/__init__.py @@ -2,6 +2,7 @@ import logging from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from .const import DOMAIN +from .api import WhatsAppBridgeApi _LOGGER = logging.getLogger(__name__) @@ -12,6 +13,7 @@ async def async_setup_entry(hass: HomeAssistant, entry): """Set up entry, create devices, and clean up old orphaned devices.""" hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = entry.data + hass.data[DOMAIN].setdefault("api", WhatsAppBridgeApi(hass)) device_registry = dr.async_get(hass) @@ -40,7 +42,7 @@ async def async_setup_entry(hass: HomeAssistant, entry): name=f"WhatsApp {entry.title}", manufacturer="Bahmcloud", model="Kontakt-Endpunkt", - sw_version="0.2.4", + sw_version="0.3.0", ) # 3. Zentralen System-Hub anlegen mit erzwungener config_entry_id Bindung (HA Core Anforderung) @@ -51,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry): name="WhatsApp Bridge Hub", manufacturer="Bahmcloud", model="Bridge Server", - sw_version="0.2.4", + sw_version="0.3.0", ) # Forward the setup to the sensor platform to create entities @@ -69,4 +71,7 @@ async def async_unload_entry(hass: HomeAssistant, entry): unload_ok = await hass.config_entries.async_unload_platforms(entry, ["sensor"]) if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok \ No newline at end of file + if hass.data[DOMAIN].get("global_entities_entry_id") == entry.entry_id: + hass.data[DOMAIN].pop("global_entities_entry_id", None) + hass.data[DOMAIN].pop("global_entities_registered", None) + return unload_ok diff --git a/custom_components/whatsapp_bridge_integration/api.py b/custom_components/whatsapp_bridge_integration/api.py new file mode 100644 index 0000000..d0e38ea --- /dev/null +++ b/custom_components/whatsapp_bridge_integration/api.py @@ -0,0 +1,93 @@ +"""Client for the WhatsApp Bridge add-on API.""" + +from __future__ import annotations + +import logging +import os +from typing import Any + +import aiohttp + +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +_LOGGER = logging.getLogger(__name__) + +ADDON_NAME = "WhatsApp Bridge" +ADDON_SLUG = "whatsapp_bridge" +ADDON_PORT = 3000 +LEGACY_BASE_URL = "http://866fd2eb-whatsapp-bridge:3000" +SUPERVISOR_ADDONS_URL = "http://supervisor/addons" + + +def _addon_base_url(addons: list[dict[str, Any]]) -> str | None: + """Return the internal URL for the installed WhatsApp Bridge add-on.""" + for addon in addons: + slug = str(addon.get("slug", "")) + name = str(addon.get("name", "")) + if name.casefold() == ADDON_NAME.casefold() or slug == ADDON_SLUG or slug.endswith( + f"_{ADDON_SLUG}" + ): + return f"http://{slug.replace('_', '-')}:{ADDON_PORT}" + return None + + +class WhatsAppBridgeApi: + """Resolve and communicate with the installed add-on.""" + + def __init__(self, hass) -> None: + self._session = async_get_clientsession(hass) + self._base_url: str | None = None + + async def _async_resolve_base_url(self) -> str: + if self._base_url is not None: + return self._base_url + + token = os.environ.get("SUPERVISOR_TOKEN") + if token: + try: + async with self._session.get( + SUPERVISOR_ADDONS_URL, + headers={"Authorization": f"Bearer {token}"}, + timeout=aiohttp.ClientTimeout(total=5), + ) as response: + response.raise_for_status() + payload = await response.json() + data = payload.get("data", payload) + resolved = _addon_base_url(data.get("addons", [])) + if resolved: + self._base_url = resolved + _LOGGER.debug("WhatsApp Bridge add-on found at %s", resolved) + return resolved + _LOGGER.warning("WhatsApp Bridge add-on was not found by Supervisor") + except (aiohttp.ClientError, TimeoutError, ValueError, TypeError) as err: + _LOGGER.warning("Could not discover WhatsApp Bridge add-on: %s", err) + + return LEGACY_BASE_URL + + async def async_get_status(self) -> dict[str, Any]: + """Fetch the current bridge status.""" + base_url = await self._async_resolve_base_url() + async with self._session.get( + f"{base_url}/api/status", + timeout=aiohttp.ClientTimeout(total=8), + ) as response: + response.raise_for_status() + data = await response.json() + if not isinstance(data, dict): + raise ValueError("Status response is not an object") + return data + + async def async_send_message(self, number: str, message: str) -> None: + """Send one WhatsApp message and surface add-on errors to Home Assistant.""" + base_url = await self._async_resolve_base_url() + async with self._session.post( + f"{base_url}/send", + json={"number": number, "message": message}, + timeout=aiohttp.ClientTimeout(total=15), + ) as response: + if response.status >= 400: + try: + detail = (await response.json()).get("error") + except (aiohttp.ContentTypeError, ValueError, AttributeError): + detail = await response.text() + raise RuntimeError(detail or f"HTTP {response.status}") diff --git a/custom_components/whatsapp_bridge_integration/manifest.json b/custom_components/whatsapp_bridge_integration/manifest.json index 807339d..1d4b293 100644 --- a/custom_components/whatsapp_bridge_integration/manifest.json +++ b/custom_components/whatsapp_bridge_integration/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://git.bahmcloud.de/bahmcloud/Whatsapp-Bridge-Integration", "dependencies": [], "codeowners": ["@bahmcloud"], - "version": "0.2.4", + "version": "0.3.0", "iot_class": "local_polling", "config_flow": true -} \ No newline at end of file +} diff --git a/custom_components/whatsapp_bridge_integration/sensor.py b/custom_components/whatsapp_bridge_integration/sensor.py index ba9ff9c..8b61a5d 100644 --- a/custom_components/whatsapp_bridge_integration/sensor.py +++ b/custom_components/whatsapp_bridge_integration/sensor.py @@ -1,5 +1,4 @@ import logging -import async_timeout import aiohttp from datetime import timedelta @@ -15,18 +14,14 @@ SCAN_INTERVAL = timedelta(seconds=15) async def async_setup_entry(hass, entry, async_add_entities): """Add sensors for WhatsApp accounts, live status, and a global broadcast sensor.""" + api = hass.data[DOMAIN]["api"] + # Coordinator für den echten Live-Status vom Add-on einrichten async def _async_update_data(): - url = "http://866fd2eb-whatsapp-bridge:3000/api/status" - async with aiohttp.ClientSession() as session: - try: - async with async_timeout.timeout(5): - async with session.get(url) as response: - if response.status != 200: - raise UpdateFailed(f"API meldet Fehler {response.status}") - return await response.json() - except Exception as err: - raise UpdateFailed(f"Add-on nicht erreichbar: {err}") + try: + return await api.async_get_status() + except (aiohttp.ClientError, TimeoutError, ValueError) as err: + raise UpdateFailed(f"Add-on nicht erreichbar: {err}") from err coordinator = DataUpdateCoordinator( hass, @@ -36,9 +31,8 @@ async def async_setup_entry(hass, entry, async_add_entities): update_interval=SCAN_INTERVAL, ) - try: - await coordinator.async_config_entry_first_refresh() - except UpdateFailed: + await coordinator.async_refresh() + if not coordinator.last_update_success: _LOGGER.warning("Erster Status-Abruf fehlgeschlagen. Add-on läuft eventuell noch an.") # Jeder Account bekommt immer seine eigene Entität @@ -49,6 +43,7 @@ async def async_setup_entry(hass, entry, async_add_entities): entities.append(WhatsAppStatusSensor(coordinator)) entities.append(WhatsAppBroadcastEntity()) hass.data[DOMAIN]["global_entities_registered"] = True + hass.data[DOMAIN]["global_entities_entry_id"] = entry.entry_id async_add_entities(entities, True) @@ -129,6 +124,8 @@ class WhatsAppStatusSensor(SensorEntity): return "WAITING_FOR_SCAN" elif data.get("isInitializing"): return "INITIALIZING" + elif data.get("reconnectAttempts", 0) > 0: + return "RECONNECTING" return "UNKNOWN" @@ -139,7 +136,10 @@ class WhatsAppStatusSensor(SensorEntity): if data: return { "client_state": data.get("clientState", "UNKNOWN"), - "has_qr_code": data.get("hasQr", False) + "has_qr_code": data.get("hasQr", False), + "is_initializing": data.get("isInitializing", False), + "reconnect_attempts": data.get("reconnectAttempts", 0), + "last_error": data.get("lastError"), } return {} @@ -154,4 +154,4 @@ class WhatsAppStatusSensor(SensorEntity): """Registrieren beim Live-Coordinator.""" self.async_on_remove( self.coordinator.async_add_listener(self.async_write_ha_state) - ) \ No newline at end of file + ) diff --git a/custom_components/whatsapp_bridge_integration/services.py b/custom_components/whatsapp_bridge_integration/services.py index e52b2dc..81d1924 100644 --- a/custom_components/whatsapp_bridge_integration/services.py +++ b/custom_components/whatsapp_bridge_integration/services.py @@ -1,6 +1,7 @@ -import requests import logging +import aiohttp from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN, CONF_PHONE_NUMBER _LOGGER = logging.getLogger(__name__) @@ -9,6 +10,8 @@ async def async_setup_services(hass: HomeAssistant): async def handle_send_message(call: ServiceCall): message = call.data.get("message") recipient = call.data.get("recipient") + if not recipient or message is None: + raise HomeAssistantError("Empfänger und Nachricht sind erforderlich") targets = [] entries = hass.config_entries.async_entries(DOMAIN) @@ -29,16 +32,18 @@ async def async_setup_services(hass: HomeAssistant): else: targets.append(recipient) + api = hass.data[DOMAIN]["api"] + # Senden for num in targets: if not num: continue - url = "http://866fd2eb-whatsapp-bridge:3000/send" try: - await hass.async_add_executor_job( - lambda: requests.post(url, json={"number": num, "message": message}, timeout=10) - ) + await api.async_send_message(num, message) _LOGGER.info("WhatsApp sent to %s", num) - except Exception as e: - _LOGGER.error("Error sending to %s: %s", num, str(e)) + except (aiohttp.ClientError, TimeoutError, RuntimeError) as err: + _LOGGER.error("Error sending to %s: %s", num, err) + raise HomeAssistantError( + f"WhatsApp-Nachricht an {num} fehlgeschlagen: {err}" + ) from err - hass.services.async_register(DOMAIN, "send_message", handle_send_message) \ No newline at end of file + hass.services.async_register(DOMAIN, "send_message", handle_send_message)