18 Commits
Author SHA1 Message Date
bahmcloud 82cb0524cc Fix add-on discovery and bridge communication 2026-08-04 14:09:59 +02:00
bahmcloud a2db56e704 custom_components/whatsapp_bridge_integration/__init__.py aktualisiert 2026-06-15 09:57:12 +00:00
bahmcloud 6a74cc7f2e custom_components/whatsapp_bridge_integration/manifest.json aktualisiert 2026-06-15 09:56:54 +00:00
bahmcloud 2701a6f908 CHANGELOG.md aktualisiert 2026-06-15 09:56:41 +00:00
bahmcloud 1d85bd7905 custom_components/whatsapp_bridge_integration/sensor.py aktualisiert
0.2.3
2026-06-15 09:52:35 +00:00
bahmcloud d4ede6669a custom_components/whatsapp_bridge_integration/__init__.py aktualisiert
0.2.3
2026-06-15 09:52:16 +00:00
bahmcloud eb6be8cdf4 custom_components/whatsapp_bridge_integration/manifest.json aktualisiert
0.2.3
2026-06-15 09:51:57 +00:00
bahmcloud 2c62edfa44 CHANGELOG.md aktualisiert
0.2.3
2026-06-15 09:51:42 +00:00
bahmcloud faed01cd8f CHANGELOG.md aktualisiert 2026-06-15 09:38:41 +00:00
bahmcloud 7175522f4a custom_components/whatsapp_bridge_integration/sensor.py aktualisiert
0.2.2
2026-06-15 09:37:06 +00:00
bahmcloud 3b99c037ae custom_components/whatsapp_bridge_integration/__init__.py aktualisiert
0.2.2
2026-06-15 09:36:42 +00:00
bahmcloud 4f8a4304ba custom_components/whatsapp_bridge_integration/manifest.json aktualisiert
0.2.2
2026-06-15 09:36:19 +00:00
bahmcloud bcff98b44b CHANGELOG.md aktualisiert
0.2.2
2026-06-15 09:36:00 +00:00
bahmcloud 40f74f9b8b CHANGELOG.md aktualisiert 2026-06-15 09:27:36 +00:00
bahmcloud 5e1c07cecf CHANGELOG.md aktualisiert
0.2.1
2026-06-15 09:26:57 +00:00
bahmcloud 2cfb881cc0 custom_components/whatsapp_bridge_integration/sensor.py aktualisiert
0.2.1
2026-06-15 09:26:10 +00:00
bahmcloud c5d216e458 custom_components/whatsapp_bridge_integration/__init__.py aktualisiert
0.2.1
2026-06-15 09:25:47 +00:00
bahmcloud 4f08cbbd6d custom_components/whatsapp_bridge_integration/manifest.json aktualisiert
0.2.1
2026-06-15 09:25:26 +00:00
6 changed files with 210 additions and 60 deletions
+27 -4
View File
@@ -1,10 +1,33 @@
# CHANGELOG - Whatsapp Bridge Integration # 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.
## Release 0.2.3 - 2026-06-15
- **New (Orphaned Devices Cleaner):** Added an automatic database cleanup utility. On startup, the integration now automatically identifies and removes orphaned or "ghost" devices left behind by previous configuration changes or older versions.
- **Fix:** Decoupled the central "WhatsApp Bridge Hub" device from specific config entry bindings. The Hub is now persistent and indestructible, preventing global entities (Broadcast and Live Status) from disappearing when individual user profiles are deleted or re-added.
## Release 0.2.2 - 2026-06-15
- **Architecture Change:** Introduced a dedicated "WhatsApp Bridge Hub" device. This central device acts as a single point of reference for global components, preventing UI clutter.
- **Fix:** Moved the global live status sensor and the virtual broadcast entity to the new WhatsApp Bridge Hub device. This ensures they remain fully visible and accessible across the Home Assistant UI, automations, and service calls without being duplicated across individual user accounts.
- **Improvement:** Maintained isolated, clean individual devices for each configured account profile.
## Release 0.2.1 - 2026-06-15
- **Fix:** Resolved device assignment duplication where a single global gateway card duplicated across all account tabs.
- **Fix:** Globalized the live status sensor and the broadcast entity to instantiate exactly once for the entire integration rather than incorrectly duplicating per account.
- **Optimization:** Code refactoring within `sensor.py` to prevent entity duplication when utilizing multiple profiles.
## Release 0.2.0 - 2026-06-15 ## Release 0.2.0 - 2026-06-15
- New: Setup of a main device (WhatsApp Bridge Gateway) in the Home Assistant device registry. - **New:** Setup of a main device (WhatsApp Bridge Gateway) in the Home Assistant device registry.
- New: Integrated global service `whatsapp_bridge.send_broadcast`, which operates independently of individual profile entities and can send messages to any number or group ID. - **New:** Integrated global service `whatsapp_bridge.send_broadcast`, which operates independently of individual profile entities and can send messages to any number or group ID.
- New: Added status sensor (`sensor.whatsapp_bridge_status`). This queries the add-on's new API and provides the current connection status (`CONNECTED`, `WAITING_FOR_SCAN`, `INITIALIZING`, `OFFLINE`) directly within Home Assistant. - **New:** Added status sensor (`sensor.whatsapp_bridge_status`). This queries the add-on's new API and provides the current connection status (`CONNECTED`, `WAITING_FOR_SCAN`, `INITIALIZING`, `OFFLINE`) directly within Home Assistant.
- Improved: The sensor is assigned to the new main device, which significantly simplifies automations (e.g., alert on connection loss). - **Improved:** The sensor is assigned to the new main device, which significantly simplifies automations (e.g., alert on connection loss).
## Release 0.1.7 ## Release 0.1.7
- Previous stable version - Previous stable version
@@ -2,6 +2,7 @@ import logging
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from .const import DOMAIN from .const import DOMAIN
from .api import WhatsAppBridgeApi
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -9,20 +10,50 @@ async def async_setup(hass: HomeAssistant, config: dict):
return True return True
async def async_setup_entry(hass: HomeAssistant, entry): async def async_setup_entry(hass: HomeAssistant, entry):
"""Set up entry, create general device and forward to sensor platform.""" """Set up entry, create devices, and clean up old orphaned devices."""
# Lege die Daten im hass-Objekt ab
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = entry.data hass.data[DOMAIN][entry.entry_id] = entry.data
hass.data[DOMAIN].setdefault("api", WhatsAppBridgeApi(hass))
# 1. Hauptgerät (General Device) im Device Registry anlegen
device_registry = dr.async_get(hass) device_registry = dr.async_get(hass)
# 1. Automatisches Aufräumen von alten Geister-Geräten
active_entry_ids = set(hass.config_entries.async_entries(DOMAIN))
active_ids = {e.entry_id for e in active_entry_ids}
# Durchsuche die HA-Datenbank nach Geräten unserer Integration
devices_to_clean = [
dev for dev in device_registry.devices.values()
if any(identifier[0] == DOMAIN for identifier in dev.identifiers)
]
for device in devices_to_clean:
# Falls ein Gerät zu einem gelöschten Config Entry gehört -> Löschen!
# Der globale Hub ("global_bridge_hub") wird ignoriert und bleibt erhalten.
for config_id in device.config_entries:
if config_id not in active_ids and not any(idx == "global_bridge_hub" for _, idx in device.identifiers):
_LOGGER.info(f"Entferne verwaistes WhatsApp-Gerät: {device.name}")
device_registry.async_remove_device(device.id)
# 2. Eigenes Gerät SPEZIFISCH für dieses aktive Konto anlegen
device_registry.async_get_or_create( device_registry.async_get_or_create(
config_entry_id=entry.entry_id, config_entry_id=entry.entry_id,
identifiers={(DOMAIN, "general_gateway")}, identifiers={(DOMAIN, entry.entry_id)},
name="WhatsApp Bridge Gateway", name=f"WhatsApp {entry.title}",
manufacturer="Bahmcloud", manufacturer="Bahmcloud",
model="Bridge Server v0.2.0", model="Kontakt-Endpunkt",
sw_version="0.2.0", sw_version="0.3.0",
)
# 3. Zentralen System-Hub anlegen mit erzwungener config_entry_id Bindung (HA Core Anforderung)
# Nutzt Multi-Entry-Zusammenführung über den fixen "global_bridge_hub" Identifier.
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, "global_bridge_hub")},
name="WhatsApp Bridge Hub",
manufacturer="Bahmcloud",
model="Bridge Server",
sw_version="0.3.0",
) )
# Forward the setup to the sensor platform to create entities # Forward the setup to the sensor platform to create entities
@@ -40,4 +71,7 @@ async def async_unload_entry(hass: HomeAssistant, entry):
unload_ok = await hass.config_entries.async_unload_platforms(entry, ["sensor"]) unload_ok = await hass.config_entries.async_unload_platforms(entry, ["sensor"])
if unload_ok: if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id) hass.data[DOMAIN].pop(entry.entry_id)
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 return unload_ok
@@ -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}")
@@ -4,7 +4,7 @@
"documentation": "https://git.bahmcloud.de/bahmcloud/Whatsapp-Bridge-Integration", "documentation": "https://git.bahmcloud.de/bahmcloud/Whatsapp-Bridge-Integration",
"dependencies": [], "dependencies": [],
"codeowners": ["@bahmcloud"], "codeowners": ["@bahmcloud"],
"version": "0.2.0", "version": "0.3.0",
"iot_class": "local_polling", "iot_class": "local_polling",
"config_flow": true "config_flow": true
} }
@@ -1,5 +1,4 @@
import logging import logging
import async_timeout
import aiohttp import aiohttp
from datetime import timedelta from datetime import timedelta
@@ -10,24 +9,19 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# Polling-Intervall (15 Sekunden)
SCAN_INTERVAL = timedelta(seconds=15) SCAN_INTERVAL = timedelta(seconds=15)
async def async_setup_entry(hass, entry, async_add_entities): async def async_setup_entry(hass, entry, async_add_entities):
"""Add sensors for WhatsApp accounts, live status, and a global broadcast sensor.""" """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 # Coordinator für den echten Live-Status vom Add-on einrichten
async def _async_update_data(): async def _async_update_data():
url = "http://866fd2eb-whatsapp-bridge:3000/api/status" try:
async with aiohttp.ClientSession() as session: return await api.async_get_status()
try: except (aiohttp.ClientError, TimeoutError, ValueError) as err:
async with async_timeout.timeout(5): raise UpdateFailed(f"Add-on nicht erreichbar: {err}") from err
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}")
coordinator = DataUpdateCoordinator( coordinator = DataUpdateCoordinator(
hass, hass,
@@ -37,22 +31,19 @@ async def async_setup_entry(hass, entry, async_add_entities):
update_interval=SCAN_INTERVAL, update_interval=SCAN_INTERVAL,
) )
# Ersten Abruf vor dem Erstellen der Entitäten erzwingen await coordinator.async_refresh()
try: if not coordinator.last_update_success:
await coordinator.async_config_entry_first_refresh()
except UpdateFailed:
_LOGGER.warning("Erster Status-Abruf fehlgeschlagen. Add-on läuft eventuell noch an.") _LOGGER.warning("Erster Status-Abruf fehlgeschlagen. Add-on läuft eventuell noch an.")
# Basis-Entitäten: Account-Sensor und der neue Live-Status-Sensor # Jeder Account bekommt immer seine eigene Entität
entities = [ entities = [WhatsAppAccountEntity(entry)]
WhatsAppAccountEntity(entry),
WhatsAppStatusSensor(coordinator, entry)
]
# Prüfen, ob die Broadcast-Entität schon existiert # Globale Entitäten (Status & Broadcast) NUR EINMALIG für die gesamte Integration anlegen
if not hass.data[DOMAIN].get("broadcast_registered"): if not hass.data[DOMAIN].get("global_entities_registered"):
entities.append(WhatsAppStatusSensor(coordinator))
entities.append(WhatsAppBroadcastEntity()) entities.append(WhatsAppBroadcastEntity())
hass.data[DOMAIN]["broadcast_registered"] = True hass.data[DOMAIN]["global_entities_registered"] = True
hass.data[DOMAIN]["global_entities_entry_id"] = entry.entry_id
async_add_entities(entities, True) async_add_entities(entities, True)
@@ -75,14 +66,14 @@ class WhatsAppAccountEntity(SensorEntity):
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Ordnet diesen Sensor dem Hauptgerät zu.""" """Ordnet diesen Sensor seinem spezifischen Personen-Gerät zu."""
return DeviceInfo( return DeviceInfo(
identifiers={(DOMAIN, "general_gateway")}, identifiers={(DOMAIN, self._entry.entry_id)},
) )
class WhatsAppBroadcastEntity(SensorEntity): class WhatsAppBroadcastEntity(SensorEntity):
"""Virtual 'All Accounts' Entity.""" """Virtual 'All Accounts' Entity, geordnet unter dem zentralen Hub."""
def __init__(self): def __init__(self):
self._attr_name = "WhatsApp Broadcast (All)" self._attr_name = "WhatsApp Broadcast (All)"
self._attr_unique_id = "whatsapp_bridge_broadcast_all" self._attr_unique_id = "whatsapp_bridge_broadcast_all"
@@ -97,20 +88,19 @@ class WhatsAppBroadcastEntity(SensorEntity):
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Ordnet diesen Sensor dem Hauptgerät zu.""" """Ordnet diese Entität dem permanenten, zentralen Hub-Gerät zu."""
return DeviceInfo( return DeviceInfo(
identifiers={(DOMAIN, "general_gateway")}, identifiers={(DOMAIN, "global_bridge_hub")},
) )
class WhatsAppStatusSensor(SensorEntity): class WhatsAppStatusSensor(SensorEntity):
"""Echter Live-Status-Sensor, gekoppelt an die Add-on API.""" """Echter Live-Status-Sensor, geordnet unter dem zentralen Hub."""
def __init__(self, coordinator, entry): def __init__(self, coordinator):
self.coordinator = coordinator self.coordinator = coordinator
self._entry = entry
self._attr_name = "WhatsApp Bridge Status" self._attr_name = "WhatsApp Bridge Status"
self._attr_unique_id = f"{entry.entry_id}_bridge_live_status" self._attr_unique_id = "whatsapp_bridge_live_status_global"
self._attr_icon = "mdi:whatsapp" self._attr_icon = "mdi:whatsapp"
@property @property
@@ -134,6 +124,8 @@ class WhatsAppStatusSensor(SensorEntity):
return "WAITING_FOR_SCAN" return "WAITING_FOR_SCAN"
elif data.get("isInitializing"): elif data.get("isInitializing"):
return "INITIALIZING" return "INITIALIZING"
elif data.get("reconnectAttempts", 0) > 0:
return "RECONNECTING"
return "UNKNOWN" return "UNKNOWN"
@@ -144,15 +136,18 @@ class WhatsAppStatusSensor(SensorEntity):
if data: if data:
return { return {
"client_state": data.get("clientState", "UNKNOWN"), "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 {} return {}
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Ordnet diesen Sensor dem Hauptgerät zu.""" """Ordnet diese Entität dem permanenten, zentralen Hub-Gerät zu."""
return DeviceInfo( return DeviceInfo(
identifiers={(DOMAIN, "general_gateway")}, identifiers={(DOMAIN, "global_bridge_hub")},
) )
async def async_added_to_hass(self): async def async_added_to_hass(self):
@@ -1,6 +1,7 @@
import requests
import logging import logging
import aiohttp
from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN, CONF_PHONE_NUMBER from .const import DOMAIN, CONF_PHONE_NUMBER
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -9,6 +10,8 @@ async def async_setup_services(hass: HomeAssistant):
async def handle_send_message(call: ServiceCall): async def handle_send_message(call: ServiceCall):
message = call.data.get("message") message = call.data.get("message")
recipient = call.data.get("recipient") recipient = call.data.get("recipient")
if not recipient or message is None:
raise HomeAssistantError("Empfänger und Nachricht sind erforderlich")
targets = [] targets = []
entries = hass.config_entries.async_entries(DOMAIN) entries = hass.config_entries.async_entries(DOMAIN)
@@ -29,16 +32,18 @@ async def async_setup_services(hass: HomeAssistant):
else: else:
targets.append(recipient) targets.append(recipient)
api = hass.data[DOMAIN]["api"]
# Senden # Senden
for num in targets: for num in targets:
if not num: continue if not num: continue
url = "http://866fd2eb-whatsapp-bridge:3000/send"
try: try:
await hass.async_add_executor_job( await api.async_send_message(num, message)
lambda: requests.post(url, json={"number": num, "message": message}, timeout=10)
)
_LOGGER.info("WhatsApp sent to %s", num) _LOGGER.info("WhatsApp sent to %s", num)
except Exception as e: except (aiohttp.ClientError, TimeoutError, RuntimeError) as err:
_LOGGER.error("Error sending to %s: %s", num, str(e)) _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) hass.services.async_register(DOMAIN, "send_message", handle_send_message)