Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82cb0524cc |
+6
-1
@@ -1,5 +1,10 @@
|
|||||||
# 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
|
## 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.
|
- **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.
|
- **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).
|
- **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,7 +42,7 @@ 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.4",
|
sw_version="0.3.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Zentralen System-Hub anlegen mit erzwungener config_entry_id Bindung (HA Core Anforderung)
|
# 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",
|
name="WhatsApp Bridge Hub",
|
||||||
manufacturer="Bahmcloud",
|
manufacturer="Bahmcloud",
|
||||||
model="Bridge Server",
|
model="Bridge Server",
|
||||||
sw_version="0.2.4",
|
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
|
||||||
@@ -69,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.4",
|
"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