Wer ein Smart Home mit Home Assistant betreibt, kennt das: Man ist unterwegs und will schnell wissen, ob die Heizung läuft, wie viel Solar gerade reinkommt oder ob noch jemand zuhause ist. Warum also nicht einfach eine Signal-Nachricht schreiben — und eine KI antwortet, die das gesamte Haus kennt und sogar Geräte steuern kann?
In diesem Beitrag zeige ich, wie ich einen Signal AI Chat Bot mit Home Assistant auf Proxmox und Claude (Anthropic) als Conversation Agent gebaut habe. Dazu gibt es ein KI-Chat-Dashboard direkt in der Home Assistant Oberfläche. Beide Interfaces nutzen denselben Claude-Agenten mit Zugriff auf rund 244 Entities — der Bot kann also nicht nur Fragen beantworten, sondern auch Licht einschalten, Rollladen fahren oder die Heizung regeln.
Warum Signal? Warum Claude?
Signal war für mich die erste Wahl, weil es Ende-zu-Ende verschlüsselt ist, keinen Cloud-Dienst eines grossen Konzerns voraussetzt und praktisch jeder in der Familie es bereits installiert hat. Kein extra Dashboard nötig, kein Browser öffnen — einfach eine Nachricht tippen wie an einen Freund.
Claude (Anthropic) als Conversation Agent hat sich als ideal herausgestellt, weil die natürliche Sprachverarbeitung hervorragend funktioniert. Man muss keine exakten Befehle kennen. „Mach das Licht im Wohnzimmer an“ funktioniert genauso wie „Ist noch jemand zuhause?“ oder „Wie warm ist es draußen?“. Claude versteht den Kontext und kann über die HA Conversation API direkt Geräte steuern.
Architektur im Ueberblick
Das System besteht aus drei Komponenten, die zusammenspielen:
- Signal REST API — läuft als HA Add-on auf Port 8080 und stellt eine WebSocket- und REST-Schnittstelle für Signal bereit
- AppDaemon — ebenfalls ein HA Add-on, führt ein Python-Skript aus, das sich per WebSocket mit der Signal API verbindet
- HA Conversation API — leitet Nachrichten an Claude weiter, der Zugriff auf alle exponierten Entities hat
Der Ablauf: Signal-Nachricht → WebSocket → AppDaemon → HA Conversation API → Claude → Antwort zurück an Signal. Das ganze dauert typischerweise 2–5 Sekunden.
Voraussetzungen
- Home Assistant OS auf Proxmox (oder jede andere HA-Installation)
- Add-on: Signal Messenger REST API (Community Add-on Store)
- Add-on: AppDaemon
- Anthropic Integration in Home Assistant (oder Google Generative AI für Gemini)
- Eine registrierte Telefonnummer für Signal (auch Festnetznummern möglich — Verifizierung per Sprachanruf)
Schritt 1: Signal REST API einrichten
Das Add-on Signal Messenger REST API von bbernhard installiert man über den HA Add-on Store (Community Add-ons). Nach der Installation muss die Telefonnummer registriert werden. In meinem Fall habe ich eine Münchner Festnetznummer verwendet — die Verifizierung läuft per Sprachanruf statt SMS.
Nach der Registrierung läuft die API auf Port 8080 und bietet sowohl REST-Endpoints als auch einen WebSocket unter /v1/receive/+49....
Schritt 2: Claude als Conversation Agent
In Home Assistant die Anthropic Integration einrichten (Einstellungen → Integrationen → Anthropic). Man braucht einen API-Key von Anthropic. Danach hat man einen Conversation Agent namens conversation.claude_conversation.
Wichtig: Unter Einstellungen → Sprachassistenten → Claude die Entities auswählen, die der Agent sehen und steuern darf. Bei mir sind es 244 Entities — Lichter, Thermostate, Rollladen, Sensoren, Anwesenheit. Bestimmte Geräte wie Kühlschrank oder Gefrierschrank habe ich bewusst ausgeschlossen, damit niemand versehentlich per Chat den Kühlschrank abschaltet.
Schritt 3: Der Signal Listener (AppDaemon)
Das Herzstück ist ein Python-Skript, das in AppDaemon läuft. Es verbindet sich per WebSocket mit der Signal REST API, empfängt eingehende Nachrichten, leitet sie an Claude weiter und schickt die Antwort zurück.
Python-Pakete installieren
In den AppDaemon Add-on Options unter „Python packages“ eintragen:
websockets aiohttp
Die App-Datei erstellen
Die Datei signal_listener.py kommt nach /addon_configs/a0d7b954_appdaemon/apps/:
import appdaemon.plugins.hass.hassapi as hass
import asyncio
import json
import re
import aiohttp
try:
import websockets
except ImportError:
websockets = None
def strip_markdown(text):
"""Markdown zu Plain Text für Signal konvertieren."""
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
text = re.sub(r'__(.+?)__', r'\1', text)
text = re.sub(r'\*(.+?)\*', r'\1', text)
text = re.sub(r'(?<!\w)_(.+?)_(?!\w)', r'\1', text)
text = re.sub(r'`(.+?)`', r'\1', text)
text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
text = re.sub(r'\[(.+?)\]\(.+?\)', r'\1', text)
text = re.sub(r'^[\-\*]\s+', '• ', text, flags=re.MULTILINE)
return text.strip()
class SignalReceiver(hass.Hass):
async def initialize(self):
if websockets is None:
self.log("websockets package not installed!", level="ERROR")
return
self.uri = self.args.get("signal_ws_uri")
self.own_number = self.args.get("own_number")
self.signal_api = self.args.get("signal_api")
self.ha_url = self.args.get("ha_url", "http://supervisor/core")
self.ha_token = self.args.get("ha_token", "")
self.allowed_numbers = self.args.get("allowed_numbers", [])
self.conversations = {}
self.group_map = {}
self.log(f"Starting Signal AI Bot on {self.uri}")
await self.load_group_map()
self.create_task(self.listen_to_signal())
async def load_group_map(self):
"""Gruppen-Mapping laden: internal_id → API id für v2/send."""
url = f"{self.signal_api}/v1/groups/{self.own_number}"
try:
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
if resp.status == 200:
groups = await resp.json()
for g in groups:
internal_id = g.get("internal_id", "")
api_id = g.get("id", "")
if internal_id and api_id:
self.group_map[internal_id] = api_id
self.log(f"Loaded {len(self.group_map)} group mappings")
except Exception as e:
self.log(f"Error loading groups: {e}", level="WARNING")
async def listen_to_signal(self):
"""WebSocket-Listener mit Auto-Reconnect."""
while True:
try:
async with websockets.connect(self.uri) as ws:
self.log("Connected to Signal WebSocket")
while True:
raw = await ws.recv()
try:
data = json.loads(raw)
except json.JSONDecodeError:
continue
envelope = data.get("envelope", {})
source = (envelope.get("sourceNumber")
or envelope.get("source", ""))
data_msg = envelope.get("dataMessage", {})
message = data_msg.get("message", "")
group_info = data_msg.get("groupInfo", {})
group_id = group_info.get("groupId", "")
if source == self.own_number or not message:
continue
if self.allowed_numbers and source not in self.allowed_numbers:
continue
self.fire_event("signal_message_received",
source=source, message=message,
group_id=group_id)
try:
response = await self.process_with_ai(source, message)
if response:
await self.send_signal_reply(source, response,
group_id)
except Exception as e:
self.log(f"AI error: {e}", level="ERROR")
except Exception as e:
self.log(f"WebSocket error: {e}", level="ERROR")
await asyncio.sleep(10)
async def process_with_ai(self, source, message):
"""Nachricht an Claude via HA Conversation API senden."""
conv_id = self.conversations.get(source)
payload = {
"text": message,
"agent_id": "conversation.claude_conversation",
"language": "de",
}
if conv_id:
payload["conversation_id"] = conv_id
headers = {
"Authorization": f"Bearer {self.ha_token}",
"Content-Type": "application/json",
}
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.ha_url}/api/conversation/process",
json=payload, headers=headers,
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
if resp.status == 200:
result = await resp.json()
new_conv_id = result.get("conversation_id")
if new_conv_id:
self.conversations[source] = new_conv_id
speech = (result.get("response", {})
.get("speech", {})
.get("plain", {})
.get("speech", ""))
return strip_markdown(speech)
else:
body = await resp.text()
self.log(f"API failed ({resp.status}): {body}",
level="ERROR")
return None
except Exception as e:
self.log(f"HA API error: {e}", level="ERROR")
return None
async def send_signal_reply(self, source, text, group_id=""):
"""Antwort via Signal REST API senden."""
url = f"{self.signal_api}/v2/send"
if group_id:
api_id = self.group_map.get(group_id)
if not api_id:
await self.load_group_map()
api_id = self.group_map.get(group_id)
if not api_id:
self.log(f"Unknown group: {group_id}", level="ERROR")
return
payload = {"message": text, "number": self.own_number,
"recipients": [api_id]}
else:
payload = {"message": text, "number": self.own_number,
"recipients": [source]}
try:
async with aiohttp.ClientSession() as session:
async with session.post(url, json=payload) as resp:
if resp.status not in (200, 201):
body = await resp.text()
self.log(f"Send failed ({resp.status}): {body}",
level="ERROR")
except Exception as e:
self.log(f"Send error: {e}", level="ERROR")
Die Gruppen-Herausforderung
Das war der kniffligste Teil des ganzen Projekts. Signal-Gruppennachrichten kommen über den WebSocket mit einer internal_id als groupId. Wenn man aber über die /v2/send API antworten will, erwartet die API ein komplett anderes ID-Format — die sogenannte id mit einem group. Prefix und anderem Base64-Encoding.
Die Lösung: Beim Start alle Gruppen von der Signal API laden (/v1/groups/NUMMER) und ein Mapping von internal_id zu id erstellen. Falls eine unbekannte Gruppe auftaucht, wird das Mapping automatisch neu geladen.
AppDaemon Konfiguration
Die apps.yaml ist übersichtlich — alle Parameter werden hier konfiguriert:
signal_receiver:
module: signal_listener
class: SignalReceiver
signal_ws_uri: "ws://DEINE_HA_IP:8080/v1/receive/+49XXXXXXXXXX"
own_number: "+49XXXXXXXXXX"
signal_api: "http://DEINE_HA_IP:8080"
ha_url: "http://supervisor/core"
ha_token: "DEIN_LANGLEBIGER_HA_TOKEN"
allowed_numbers:
- "+49XXXXXXXXXX"
Unter allowed_numbers definiert man, wer den Bot nutzen darf. Ohne Eintrag dürfte jeder schreiben — das will man in der Regel nicht.
Bonus: KI Chat Dashboard in Home Assistant
Zusätzlich zum Signal Bot habe ich ein Chat-Interface direkt in Home Assistant gebaut. Eine HTML-Seite (ai-chat.html), eingebettet als iframe in einem Lovelace Panel-View. Das Dashboard nutzt dieselbe HA Conversation API wie der Signal Bot.
Features:
- Agent-Wechsel — zwischen Claude, Gemini und HA Assist umschaltbar
- Chat-Historie mit Sidebar (localStorage-persistent)
- Stern-Favoriten — wichtige Chats werden nie automatisch gelöscht
- 30-Tage Auto-Cleanup für alte Chats ohne Stern
- Multi-Turn Conversations — Claude merkt sich den Kontext
- Mobil-optimiert — responsive Sidebar, funktioniert auch am Handy
- In neuem Tab öffnen — praktisch wenn der Chat als iframe eingebettet ist
Sicherheit
Ein paar wichtige Punkte zur Absicherung:
- Absender-Filter: Nur eingetragene Telefonnummern können den Bot nutzen
- Entity-Exposure: Nicht alle Entities sind für Claude sichtbar — kritische Geräte wie Kühlschrank und Gefrierschrank sind bewusst ausgeschlossen
- Anwesenheits-Abfragen: Person-Entities sind exponiert, damit Claude Fragen wie „Ist jemand zuhause?“ beantworten kann
- Signal E2E-Verschlüsselung: Die Kommunikation zwischen Handy und Signal-Server ist Ende-zu-Ende verschlüsselt — innerhalb des Heimnetzwerks läuft alles lokal
HA Events für eigene Automationen
Der Signal Listener feuert bei jeder eingehenden Nachricht ein HA Event signal_message_received mit den Attributen source, message und group_id. Damit lassen sich eigene Automationen bauen — zum Beispiel: „Wenn jemand ‚Alarm‘ schreibt, Kameras einschalten„.
Tipps aus der Praxis
- Der Conversation-Kontext wird im Arbeitsspeicher gespeichert. Bei AppDaemon-Neustart geht er verloren — in der Praxis kein Problem, da Claude einzelne Fragen gut versteht
- Wer
http://supervisor/coreals HA-URL nutzt, muss AppDaemon als Add-on laufen haben. Bei Docker-Installationen die tatsächliche HA-URL verwenden - Claude antwortet gerne mit Markdown (fett, Listen, Ueberschriften). Die
strip_markdown-Funktion konvertiert alles in lesbaren Plain Text für Signal - Der Bot funktioniert mit jedem HA Conversation Agent — einfach die
agent_idändern für Gemini oder HA Assist
Fazit
Der Signal AI Bot läuft seit einigen Tagen stabil. Was mich am meisten überzeugt: Es fühlt sich natürlich an. Man schreibt „Wie warm ist es im Wohnzimmer?“ oder „Mach die Rollladen im Schlafzimmer runter“ — und es passiert. Kein Dashboard öffnen, kein Login, kein spezieller Syntax.
Die Kombination aus Signal (überall verfügbar, verschlüsselt) und Claude (versteht natürliche Sprache, kann Geräte steuern) macht das Setup alltagstauglich. Der gesamte Code läuft lokal auf dem Home Assistant Server — kein externer Dienst ausser der Anthropic API für Claude.
Weitere Home Assistant Anleitungen: