Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82cb0524cc | ||
|
|
a2db56e704 | ||
|
|
6a74cc7f2e | ||
|
|
2701a6f908 |
+10
-1
@@ -1,5 +1,14 @@
|
|||||||
# 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
|
## 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.
|
- **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.
|
- **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.
|
||||||
@@ -21,4 +30,4 @@
|
|||||||
- **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__)
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ async def async_setup_entry(hass: HomeAssistant, entry):
|
|||||||
"""Set up entry, create devices, and clean up old orphaned devices."""
|
"""Set up entry, create devices, and clean up old orphaned devices."""
|
||||||
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))
|
||||||
|
|
||||||
device_registry = dr.async_get(hass)
|
device_registry = dr.async_get(hass)
|
||||||
|
|
||||||
@@ -40,16 +42,18 @@ async def async_setup_entry(hass: HomeAssistant, entry):
|
|||||||
name=f"WhatsApp {entry.title}",
|
name=f"WhatsApp {entry.title}",
|
||||||
manufacturer="Bahmcloud",
|
manufacturer="Bahmcloud",
|
||||||
model="Kontakt-Endpunkt",
|
model="Kontakt-Endpunkt",
|
||||||
sw_version="0.2.3",
|
sw_version="0.3.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Zentralen System-Hub VOLLKOMMEN UNABHÄNGIG anlegen (ohne feste config_entry_id Bindung)
|
# 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(
|
device_registry.async_get_or_create(
|
||||||
|
config_entry_id=entry.entry_id,
|
||||||
identifiers={(DOMAIN, "global_bridge_hub")},
|
identifiers={(DOMAIN, "global_bridge_hub")},
|
||||||
name="WhatsApp Bridge Hub",
|
name="WhatsApp Bridge Hub",
|
||||||
manufacturer="Bahmcloud",
|
manufacturer="Bahmcloud",
|
||||||
model="Bridge Server",
|
model="Bridge Server",
|
||||||
sw_version="0.2.3",
|
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
|
||||||
@@ -67,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)
|
||||||
return unload_ok
|
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
|
||||||
|
|||||||
@@ -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.3",
|
"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
|
||||||
|
|
||||||
@@ -15,18 +14,14 @@ 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,
|
||||||
@@ -36,9 +31,8 @@ async def async_setup_entry(hass, entry, async_add_entities):
|
|||||||
update_interval=SCAN_INTERVAL,
|
update_interval=SCAN_INTERVAL,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
await coordinator.async_refresh()
|
||||||
await coordinator.async_config_entry_first_refresh()
|
if not coordinator.last_update_success:
|
||||||
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.")
|
||||||
|
|
||||||
# Jeder Account bekommt immer seine eigene Entität
|
# 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(WhatsAppStatusSensor(coordinator))
|
||||||
entities.append(WhatsAppBroadcastEntity())
|
entities.append(WhatsAppBroadcastEntity())
|
||||||
hass.data[DOMAIN]["global_entities_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)
|
||||||
|
|
||||||
@@ -129,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"
|
||||||
|
|
||||||
@@ -139,7 +136,10 @@ 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 {}
|
||||||
|
|
||||||
@@ -154,4 +154,4 @@ class WhatsAppStatusSensor(SensorEntity):
|
|||||||
"""Registrieren beim Live-Coordinator."""
|
"""Registrieren beim Live-Coordinator."""
|
||||||
self.async_on_remove(
|
self.async_on_remove(
|
||||||
self.coordinator.async_add_listener(self.async_write_ha_state)
|
self.coordinator.async_add_listener(self.async_write_ha_state)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user