Das Problem

Jeden Morgen dasselbe: Kalender checken, Ticketsystem öffnen, Müllkalender studieren — das sind drei verschiedene Quellen, drei verschiedene Logins, drei verschiedene Orte, an denen man aktiv suchen muss. Und das vor dem ersten Kaffee.

Ich wollte einen Protokolldroide für die Küche. Einen Dienst, der mir die relevanten Informationen des Tages entgegenwirft, nicht erst auf Abruf. Aber anders als C-3PO — der bekanntlich immer redet, auch wenn niemand zuhört — sollte der Bericht nur dann kommen, wenn ich wirklich in der Küche bin. Also: Wenn die Kaffeemühle läuft (Leistungssensor) oder ich einen bestimmten Wandtaster doppelt drücke.

Die Ansage selbst sollte eine natürliche, deutsche Stimme haben. Piper TTS (siehe letzter Blog-Post) funktioniert, klingt aber nunmal nach Roboter (auch wenn das hier thematisch passt). ElevenLabs Charlotte klingt wie ein echter Mensch — aber sie in Home Assistant zum Laufen zu bringen, erfordert einen Umweg.

TL;DR

Ein Python-Skript (/config/scripts/daily_report.py) mit stdlib only (kein pip install im HA-Container) wird per shell_command aufgerufen. Es sammelt drei Datenquellen:

  1. CalDAV-Kalender — cross-account per Service-Account (c3po@rebellion.local liest luke.skywalker@rebellion.locals Kalender), mit <C:expand> für Serientermine
  2. Zammad-Tickets — nur Tickets bei denen der Kunde zuletzt reagiert hat (nicht meine eigenen ausgehenden Anfragen)
  3. Müllabfuhr — Waste Collection HACS-Integration, nur „heute" oder „morgen"

Das Skript generiert eine MP3-Datei via fal.ai (ElevenLabs Charlotte), beschleunigt sie mit ffmpeg atempo=1.07 und spielt sie auf einem Yamaha MusicCast WX-010 ab. Wenn ElevenLabs nicht erreichbar ist, fällt es auf Google TTS zurück — der Briefing wird niemals still.

Trigger: automatisch via Siebträger-Kaffeemühle (Leistungssensor > 50 W) oder manuell per Zigbee-Wandtaster (Doppelklick).

Die Architektur

┌─────────────────────────────────────────────────────────────────┐
│                     droide-c3po (HA)                            │
│  192.168.66.42                                                  │
│                                                                 │
│  ┌──────────────────────┐    ┌──────────────────────────────┐   │
│  │  Zigbee2MQTT         │    │  shell_command               │   │
│  │  (Taster-Events)     │    │  daily_report.py (stdlib)    │   │
│  └────────┬─────────────┘    └──────────┬───────────────────┘   │
│           │                             │                       │
│           ▼                             ▼                       │
│  ┌─────────────────┐         ┌─────────────────────┐            │
│  │  Automation     │         │  /config/www/       │            │
│  │  daily_report   │────────►│  daily_report.mp3   │            │
│  │  (button/kofi)  │         │  (generierte MP3)   │            │
│  └────────┬────────┘         └──────────┬──────────┘            │
│           │                             │                       │
└───────────┼─────────────────────────────┼───────────────────────┘
            │                             │
            │         ┌───────────────────┘
            ▼         ▼
   ┌─────────────────────────────────────┐
   │  Yamaha MusicCast WX-010            │
   │  (media_player.kueche)              │
   │  spielt /local/daily_report.mp3     │
   └─────────────────────────────────────┘
            ▲
            │
┌───────────┴──────────────┐
│  tantive-iv ──► fal.ai   │
│  (Reverse Proxy,         │
│   ElevenLabs API via     │
│   fal.run)               │
└──────────────────────────┘

Datenquellen (alle über tantive-iv):
┌────────────┐  ┌──────────┐  ┌──────────────────────┐
│ jedi-archiv │  │ droide-r2 │  │ rebellion-base.net   │
│ (LDAP)     │  │ (Zammad  │  │ (DeepSeek API,       │
│            │  │  Docker)  │  │  ElevenLabs via fal) │
└────────────┘  └──────────┘  └──────────────────────┘

Namenskonvention (Star Wars): droide-c3po = HA-VM, droide-r2 = Automatisierungs-Host mit Zammad und Skripten, tantive-iv = Reverse Proxy (außen), jedi-archiv = LDAP. Domain: rebellion.local (intern) / rebellion-base.net (extern). IPs: 192.168.66.x — Order 66.

Die Datenquellen

1. CalDAV-Kalender: Cross-Account mit Serienterminen

Der Kalender von luke.skywalker@rebellion.local wird über einen Service-Account (c3po@rebellion.local) gelesen — das entkoppelt das Skript vom persönlichen Passwort. Die CalDAV-REPORT-Query nutzt <C:expand> für wiederkehrende Termine:

<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
  <D:prop><D:getetag/></D:prop>
  <C:filter>
    <C:comp-filter name="VCALENDAR">
      <C:comp-filter name="VEVENT">
        <C:time-range start="20260712T000000Z" end="20260720T235959Z"/>
      </C:comp-filter>
    </C:comp-filter>
  </C:filter>
  <C:expand start="20260712T000000Z" end="20260720T235959Z"/>
</C:calendar-query>

Zeitfenster: Heute + morgen + 8 Tage voraus (für die Wochenend-Übersicht). Am Wochenende wird der Block nur vorgelesen, wenn es Termine gibt — kein „heute ist nichts", das wäre nur Rauschen.

Deduplizierung: Manche CalDAV-Server liefern Duplikate (einmal als Serien-Instanz, einmal als separate Komponente). Ein Set über (dtstart, dtend, summary) löst das.

Datumssprache: Wochentage werden auf Deutsch abgekürzt (Mo, Di, Mi, Do, Fr, Sa, So), Zeiten als „um 14 Uhr":

Heute: Mo — Meeting mit der Rebellen-Allianz um 10 Uhr.
Morgen: Di — Wartung am Hyperantrieb um 14 Uhr.
def format_date_for_tts(dt: datetime) -> str:
    """Formatiert ein Datum für die Sprachausgabe (deutsch, kurz)."""
    weekday_map = {0: "Mo", 1: "Di", 2: "Mi", 3: "Fr", 4: "Do", 5: "Sa", 6: "So"}
    wd = weekday_map[dt.weekday()]
    return f"{wd}{dt.day}.{dt.month:02d}."

2. Zammad-Ticketsystem: Nur relevante Neuigkeiten

Die erste Version las alle Tickets der letzten 24 Stunden — inklusive der Tickets, die luke.skywalker selbst erstellt hatte und auf eine Antwort wartete. Ergebnis: Am Morgen hörte man seine eigenen ausgehenden Anfragen nochmal vorgelesen. Sinnlos.

Lösung: Statt created_at wird last_contact_customer_at >= cutoff gefiltert. Das zeigt nur Tickets, bei denen der Kunde zuletzt geschrieben hat — also echte Eingänge, auf die niemand reagiert hat.

def fetch_tickets(base_url: str, token: str, cutoff_utc: datetime) -> list:
    """Holt Tickets bei denen der Kunde zuletzt reagiert hat."""
    params = urllib.parse.urlencode({
        "page": 1,
        "per_page": 100,
        "state_open": "true",
        "last_contact_customer_at": f"{cutoff_utc.isoformat()}..",
    })
    req = urllib.request.Request(
        f"{base_url}/api/v1/tickets/search?{params}",
        headers={"Authorization": f"Bearer {token}"},
    )
    with urllib.request.urlopen(req, timeout=15) as resp:
        return json.loads(resp.read()).get("assets", {}).get("Ticket", {}).values()

Titel-Bereinigung (Regel-Basis): Die Ticket-Titel aus Zammad sind oft Müll: Bestellnummern (7070049), reine Nummern als Titel (7070049:) oder führende Satzzeichen. Ein Regex-Durchlauf (wie im letzten Post über LLM-Titelbereinigung beschrieben) entfernt ≥5-stellige Zahlenblöcke und glättet Satzzeichen:

def clean_ticket_title(title: str) -> str | None:
    """Bereinigt Ticket-Titel für TTS. Gibt None zurück wenn nichts sinnvolles übrig ist."""
    if not title:
        return None
    s = re.sub(r"\b\d{5,}\b", "", title)       # Bestell-/Rechnungsnummern raus
    s = re.sub(r"\s*([:;,])\s*", r"\1 ", s)     # Satzzeichen normalisieren
    s = re.sub(r"\s*[:;,]\s*(?=[:;,])", "", s)  # Doppelte entdoppeln
    s = re.sub(r"\s+", " ", s).strip()           # Whitespace glätten
    s = s.strip(" :;,")                           # Führende/hängende Satzzeichen
    return s or None

DeepSeek-Veredelung (optional, fallback-safe): Wenn ein API-Key für DeepSeek konfiguriert ist, werden die bereinigten Titel zusätzlich durch deepseek-chat gejagt (temperature=0, JSON-Mode, strenger „nicht erfinden"-Prompt):

def summarize_tickets_llm(titles: list, api_key: str) -> list | None:
    """Veredelt Titel per DeepSeek. Gibt None bei Fehler/Timeout/unplausibler Antwort."""
    if not api_key or not titles:
        return None
    body = json.dumps({
        "model": "deepseek-chat",
        "temperature": 0,
        "response_format": {"type": "json_object"},
        "messages": [
            {"role": "system", "content": (
                "Du bist ein Textbereiniger für eine gesprochene Ansage. "
                "Du bekommst eine Liste von Ticket-Titeln. "
                "Gib GENAU EINEN bereinigten Eintrag pro Titel zurück, "
                "gleiche Reihenfolge, knapp und natürlich. "
                "Erfinde NICHTS dazu.")},
            {"role": "user", "content": json.dumps(titles, ensure_ascii=False)},
        ],
    }).encode("utf-8")
    req = urllib.request.Request(
        "https://api.deepseek.com/chat/completions",
        data=body, method="POST",
        headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
    )
    try:
        with urllib.request.urlopen(req, timeout=15) as r:
            data = json.loads(r.read())
        items = json.loads(data["choices"][0]["message"]["content"]).get("items")
        if isinstance(items, list) and len(items) == len(titles) \
                and all(isinstance(x, str) and x.strip() for x in items):
            return [x.strip() for x in items]
        return None
    except Exception:
        return None  # Jeder Fehler → deterministischer Fallback

Der entscheidende Schutz: Wenn DeepSeek ein falsche Anzahl von Titeln zurücklieft, oder die Antwort nicht parsebar ist, wird die deterministische Regelversion verwendet. Keine Halluzinationen, keine stumme Ansage.

3. Müllabfuhr: Nur heute oder morgen

Die Waste Collection HACS-Integration liefert Sensoren für Biomüll (sensor.biomull), Hausmüll (sensor.hausmull) und Papier/Pappe (sensor.pappe_papier). Der Wert ist ein Text wie „heute", „morgen", „in 3 Tagen".

Die Regel: Alles, was nicht „heute" oder „morgen" ist, wird ignoriert. Niemand will sich „in 5 Tagen ist Papiermüll" anhören — das merkt man sich nicht, und es rauscht nur die Ansage zu. Der ganze Block wird weggelassen, wenn nichts ansteht.

def get_waste_collection(ha_token: str) -> str:
    """Holt Müllabfuhr aus HA. Nur 'heute' oder 'morgen' relevant."""
    sensors = {
        "sensor.biomull": "Biomüll",
        "sensor.hausmull": "Hausmüll",
        "sensor.pappe_papier": "Papier und Pappe",
    }
    selections = []
    for entity_id, name in sensors.items():
        state = _ha_get_state(entity_id, ha_token)
        if state and state.get("state", "").lower() in ("heute", "morgen"):
            selections.append(f"{name} {state['state']}")
    if not selections:
        return ""  # Block komplett weglassen
    return "Müllabfuhr: " + ", ".join(selections) + "."

Die TTS-Pipeline: Von ElevenLabs bis zum Yamaha-Lautsprecher

Home Assistant hat keine native ElevenLabs-TTS-Engine. Der Weg geht deshalb über eine API:

  1. Sprachgenerierung via fal.ai/fal-ai/elevenlabs/tts/eleven-v3 — Parameter: Voice Charlotte, language_code: "de", stability: 0.4
  2. Download der generierten MP3 (audio.url aus der API-Antwort)
  3. Tempo-Anpassung mit ffmpeg -af atempo=1.07 — Charlotte spricht etwas langsam, 1.07× klingt „flott" aber natürlich
  4. Speichern nach /config/www/daily_report.mp3 (HA’s www-Ordner = statischer Dateiserver)
  5. Abspielen via media_player.play_media mit URL /local/daily_report.mp3?ts=<timestamp> — der Query-Parameter verhindert Browser-/Cache-Probleme
def generate_speech(text: str, secrets: dict) -> str | None:
    """Generiert MP3 via ElevenLabs/fal.ai. Liefert Dateipfad bei Erfolg, None bei Fehler."""
    payload = json.dumps({
        "voice": "Charlotte",
        "text": text,
        "language_code": "de",
        "stability": 0.4,
    }).encode("utf-8")

    req = urllib.request.Request(
        "https://fal.run/fal-ai/elevenlabs/tts/eleven-v3",
        data=payload,
        headers={
            "Authorization": f"Key {secrets.get('fal_api_key', '')}",
            "Content-Type": "application/json",
        },
    )
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            result = json.loads(resp.read())
        audio_url = result.get("audio", {}).get("url")
        if not audio_url:
            return None

        # MP3 herunterladen
        mp3_data = urllib.request.urlopen(audio_url, timeout=30).read()

        # Tempo anpassen
        proc = subprocess.run(
            ["ffmpeg", "-i", "-", "-af", "atempo=1.07", "-f", "mp3", "-"],
            input=mp3_data, capture_output=True, timeout=30,
        )
        if proc.returncode != 0:
            return None

        output_path = "/config/www/daily_report.mp3"
        with open(output_path, "wb") as f:
            f.write(proc.stdout)
        return output_path
    except Exception:
        return None

Fallback (kritisch): Wenn generate_speech() fehlschlägt — aus welchem Grund auch immer — wird tts.google_de_speak mit derselben Text-Vorlage aufgerufen. Google TTS ist zwar nicht so natürlich wie ElevenLabs, aber zuverlässig. Der Briefing wird nie stumm:

def speak_report(text: str, secrets: dict, ha_token: str) -> bool:
    """Spielt den Report ab. ElevenLabs-Versuch, bei Fehler Google-TTS-Fallback."""
    mp3_path = generate_speech(text, secrets)
    if mp3_path:
        # ElevenLabs-MP3 abspielen
        _ha_post(f"/api/services/media_player/turn_on", {"entity_id": MEDIA_PLAYER}, ha_token)
        time.sleep(2)
        _ha_post(f"/api/services/media_player/play_media", {
            "entity_id": MEDIA_PLAYER,
            "media_content_id": f"/local/daily_report.mp3?ts={int(time.time())}",
            "media_content_type": "audio/mpeg",
        }, ha_token)
        return True

    # Fallback: Google TTS
    _ha_post(f"/api/services/tts/google_de_speak", {
        "entity_id": "tts.google_de",
        "media_player_entity_id": MEDIA_PLAYER,
        "message": text,
    }, ha_token)
    return False

Trigger: Wann spricht C-3PO?

Automatisch: Die Siebträger-Kaffeemühle

Ein Leistungssensor (sensor.siebtraeger_electric_consumption_w) am Kaffeegrinder springt über 50 W, sobald die Mühle läuft. Die Automation prüft dann, ob der Report heute bereits gelaufen ist:

alias: daily_report_automatic
mode: single
trigger:
  - platform: numeric_state
    entity_id: sensor.siebtraeger_electric_consumption_w
    above: 50
    for: "00:00:03"
condition:
  - condition: template
    value_template: >
      {{ states('input_datetime.daily_report_last_run') == 'unknown'
         or (now().date() - as_timestamp(states('input_datetime.daily_report_last_run'))
             | timestamp_custom('%Y-%m-%d', false)
             | string != now().date() | string) }}
action:
  - service: shell_command.daily_report
  - service: input_datetime.set_datetime
    target:
      entity_id: input_datetime.daily_report_last_run
    data:
      datetime: "{{ now() }}"

Warum input_datetime statt boolean? Ein Boolean (input_boolean.report_ran_today) würde um Mitternacht zurückgesetzt werden müssen — wenn die Automation aber ausfällt oder HA nachts neustartet, bleibt der Boolean auf on stehen und der Report läuft nie wieder. Ein Datumsvergleich gegen now().date() ist selbst-resettend — jeder neue Tag ist automatisch ein neuer „frischer" Tag. Diese Lektion habe ich durch einen Garagentor-Incident gelernt, bei dem ein hängengebliebener Boolean das Tor tagelang blockierte.

Manuell: Zigbee-Wandtaster (Doppelklick)

Ein 4-fach-Zigbee-Taster sendet bei Doppelklick eine MQTT-Nachricht. Die Automation reagiert darauf — und setzt keinen daily_report_last_run:

alias: daily_report_button
mode: single
trigger:
  - platform: mqtt
    topic: "zigbee2mqtt/0x84fd27fffe123456/action"
    payload: "2_double"
action:
  - service: shell_command.daily_report

Design-Entscheidung: Der manuelle Taster setzt die Tages-Sperre nicht. So kann man C-3PO jederzeit bitten, den Bericht nochmal zu wiederholen — ohne bis morgen zu warten.

Das Python-Skript: Stdlib only

Das Skript lebt unter /config/scripts/daily_report.py und verwendet ausschließlich stdlib — kein pip install im HA-Container, keine venv-Abhängigkeiten, keine zusätzlichen Docker-Layer:

#!/usr/bin/env python3
"""Daily Report für Home Assistant — C-3PO-Ansage mit Kalender, Tickets, Müllabfuhr."""

import json, os, re, subprocess, time, urllib.request, urllib.parse
from datetime import datetime, timedelta, timezone
from xml.etree import ElementTree

# Konfiguration
CALDAV_URL = "https://tantive-iv.rebellion.local/caldav/calendars/luke.skywalker/default/"
CALDAV_USER = "c3po@rebellion.local"
ZAMMAD_URL = "https://zammad.rebellion-base.net"
MEDIA_PLAYER = "media_player.kueche"
HA_URL = "http://localhost:8123"
WINDOW_DAYS = 10  # heute + morgen + 8 Tage

def main():
    secrets = _load_secrets()
    ha_token = secrets["ha_token"]
    now = datetime.now(timezone.utc)

    text_parts = []
    text_parts.append(_get_calendar_text(secrets, now))
    text_parts.append(_get_ticket_text(secrets, now))
    text_parts.append(get_waste_collection(ha_token))

    report_text = "Hallo. " + " ".join(p for p in text_parts if p)
    speak_report(report_text, secrets, ha_token)

if __name__ == "__main__":
    main()

Secrets werden aus einer .secrets.json geladen (nicht im Repo, nicht in environment variables):

{
  "ha_token": "ey...",
  "caldav_password": "...",
  "zammad_token": "...",
  "fal_api_key": "...",
  "deepseek_api_key": "..."
}

Kritische Caveats (hart erkämpft)

1. shell_command überlebt kein Reload — nur Restart

Home Assistants shell_command wird beim Start registriert. Ein configuration.yaml-Reload (homeassistant.reload_core_config) registriert neue Shell-Commands nicht. Wenn du ein neues Skript hinzufügst oder den Pfad änderst, brauchst du einen vollen HA-Neustart (homeassistant.restart).

Das hat mich Stunden gekostet: Skript deployt, HA-Config reloadt, button.daily_report drücken — nichts. Kurz dachte ich, das Skript wäre kaputt, dabei hatte es HA nie geladen.

# Nach Änderungen an shell_command:
ha core restart   # oder via GUI: Einstellungen → System → Neustart

2. TTS-Engine (Google) ist nach HA-Neustart ~60-90 Sekunden nicht bereit

Die Google-Cloud-TTS-Integration braucht nach einem Neustart eine Weile, bis sie den ersten API-Call erfolgreich ausführt. Wenn du direkt nach einem Neustart den Taster drückst — weil HA gerade frisch ist und der Report sofort kommen soll — bekommst du Stille.

Das Skript hat dafür einen retry-Mechanismus eingebaut, der maximal 3 Versuche mit 30 Sekunden Abstand macht:

def _ha_post(endpoint, data, token, max_retries=3, retry_delay=30):
    for attempt in range(max_retries):
        try:
            req = urllib.request.Request(
                f"{HA_URL}{endpoint}",
                data=json.dumps(data).encode(),
                headers={
                    "Authorization": f"Bearer {token}",
                    "Content-Type": "application/json",
                },
            )
            with urllib.request.urlopen(req, timeout=15):
                return True
        except Exception:
            if attempt < max_retries - 1:
                time.sleep(retry_delay)
    return False

3. MQTT-Trigger braucht automation.reload nach HA-Restart

Nach einem HA-Neustart hörten die MQTT-Trigger (Zigbee-Taster Doppelklick) auf zu feuern. Die Automation war aktiv, der Topic-Listener aber tot. Ein automation.reload (oder GUI: Einstellungen → Automationen → Reload) brachte sie wieder zum Leben. Warum das passiert — unbekannt, reproduzierbar seit HA 2025.x.

4. media_player.media_play wird nicht auf allen Entities unterstützt

Die erste Version des Radio-Restore nach der Ansage erstellte ein HA-Scene-Objekt und rief scene.turn_on auf. Das brach mit HA 2026.7 mit einem 500er. Der Workaround: Statt einer Scene nach der Ansage direkt media_player.play_media mit einer bekannten Radio-Stream-URL aufrufen:

action:
  - service: media_player.play_media
    target:
      entity_id: media_player.kueche
    data:
      media_content_id: "http://www.radioeins.de/livemp3"
      media_content_type: "audio/mpeg"

Der Yamaha WX-010 im AirPlay-Slave-Mode hat keinen media_play-Service — er kann nur play_media mit einer konkreten URL. Scene-basierte Zustands-Wiederherstellung funktioniert dort nicht.

5. date-basierte Sperre statt boolean

Bereits erwähnt, aber wichtig genug für eine eigene Zeile: Kein boolean-Flag für Tages-Sperren verwenden. Ein input_datetime, das gegen now().date() verglichen wird, ist selbst-resettend und kann nie hängenbleiben.

Verifikation: 32 Unit-Tests + Dry-Run

Das Skript wurde mit 32 Unit-Tests abgedeckt — alle puren Funktionen (Text-Formatierung, Ticket-Bereinigung, Kalender-Parsing) sind isoliert testbar, weil sie nur Strings rein/raus haben:

def test_format_date_monday():
    dt = datetime(2026, 7, 13, 10, 0)
    assert format_date_for_tts(dt) == "Mo — 13.07."

def test_clean_ticket_removes_order_number():
    assert clean_ticket_title("Anfrage 7070049: Kristall") == "Anfrage Kristall"

def test_clean_ticket_returns_none_for_number_only():
    assert clean_ticket_title("7070049:") is None

def test_waste_collection_empty_when_nothing_due():
    assert get_waste_collection([]) == ""

Dry-Run-Modus: Das Skript hat einen --dry-run-Flag, der den generierten Text auf der Konsole ausgibt statt eine MP3 zu generieren:

docker exec -i homeassistant python3 /config/scripts/daily_report.py --dry-run

py_compile-Check vor Deploy: Ein Pre-Deploy-Script prüft, ob das Python überhaupt syntaktisch korrekt ist:

python3 -m py_compile /config/scripts/daily_report.py && echo "OK" || echo "Syntax-Fehler"

Deploy per deploy.sh: Das Script kopiert die Datei per scp auf droide-c3po und triggert ein docker exec homeassistant restart:

#!/bin/bash
set -e
TARGET="root@droide-c3po.rebellion.local:/root/ha-config/scripts/"

python3 -m py_compile daily_report.py

scp daily_report.py "${TARGET}"
ssh root@droide-c3po.rebellion.local \
  "docker exec homeassistant python3 -m py_compile /config/scripts/daily_report.py"
ssh root@droide-c3po.rebellion.local \
  "docker exec homeassistant ha core restart"

Kosten: ElevenLabs als Faktor

Jeder Abruf des Daily Reports verbraucht genau einen ElevenLabs-API-Call über fal.ai. Der Briefing-Text ist ~200-300 Zeichen lang — weit unter dem API-Limit. Bei einmal täglicher Nutzung (plus gelegentlichem manuellen Nachfragen) kommst du auf ~40-50 API-Calls pro Monat.

ElevenLabs Eleven-V3 kostet über fal.ai etwa 0,03–0,05 $ pro Call (Stand Juli 2026). Das sind ~1,50–2,50 $/Monat — vernachlässigbar. Wenn ElevenLabs ausfällt, übernimmt Google TTS für lau.

Kein Abo nötig: fal.ai arbeitet Pay-per-Use, kein monatliches Abonnement. Du kaufst Credits und verbrauchst sie.

Lessons Learned

1. Die Ansage darf nie stumm sein — Fallback-first-Design

Die wichtigste Erkenntnis dieses ganzen Projekts: Ein Skript, das autonom läuft (kein Mensch sitzt davor), darf nie an einem einzigen API-Call hängen. Jede externe Abhängigkeit (ElevenLabs, DeepSeek) muss einen deterministischen Fallback haben, der auch ohne die externe API ein brauchbares Ergebnis liefert. Google TTS fängt ElevenLabs-Ausfälle ab, die Regel-Basis fängt DeepSeek-Halluzinationen ab.

2. Mache die Tages-Sperre selbst-resettend

input_datetime + Datumsvergleich ist der booleschen input_boolean-Lösung haushoch überlegen. Ein hängengebliebener Boolean kann deine Automation tagelang lahmlegen — ein now().date()-Vergleich nicht.

3. shell_command ist kein Service, sondern ein Startup-Command

Es wird genau einmal beim Start von HA geladen. Wenn du ein neues Skript hinzufügst, restarte HA — ein Config-Reload reicht nicht. Lege dir einen Zettel an den Monitor, bis es reflex wird.

4. MQTT-Trigger re-subscriben nach Neustart nicht automatisch

Wenn deine Zigbee-Taster nach einem HA-Neustart nicht mehr feuern, hilft automation.reload. Warum das nötig ist, weiß niemand so genau — aber es ist reproduzierbar seit Jahren.

5. Cross-Account CalDAV über Service-Account entkoppelt

Ein Service-Account (c3po@rebellion.local) statt des persönlichen Passworts von luke.skywalker im Skript — das ist sicherer und überlebt Passwort-Änderungen des Hauptnutzers.

6. last_contact_customer_at ist der richtige Filter für Zammad

Tickets, bei denen der Kunde zuletzt geschrieben hat, sind die echten Eingänge. Der Filter auf created_at liefert auch die eigenen ausgehenden Tickets — uninteressant für eine Morgenansage.

Checkliste für den eigenen Bau

  • shell_command.daily_report registriert → HA-restart nötig (kein reload!)
  • .secrets.json liegt außerhalb des Repos und ist in .gitignore
  • py_compile-Check vor jedem Deploy
  • TTS-Fallback getestet (z. B. ElevenLabs-Key temporär ungültig machen)
  • date-basierte Tages-Sperre statt boolean-Flag
  • Taster-Automation hat automation.reload nach HA-Neustart erhalten
  • Radio-Restore nach der Ansage als play_media mit fester Stream-URL, nicht als Scene
  • Dry-Run-Modus implementiert und getestet
  • DeepSeek-Fallback verifiziert (Key entfernen → Regel-Basis läuft durch)
  • Zigbee-Taster Doppelklick: MQTT-Topic und Payload stimmen mit Z2M überein
  • CalDAV-Service-Account hat Leseberechtigung auf dem Ziel-Kalender
  • ffmpeg im HA-Container verfügbar (ggf. ffmpeg-Addon installieren)

Fazit

C-3PO in der Küche ist genau die richtige Menge an Nerd-Ernst: Ein Protokolldroide, der morgens die relevanten Informationen aus dem Rebellions-Netzwerk abruft und auf den Punkt bringt. Die ElevenLabs-Stimme ist teuerer als Piper (1,50 €/Monat vs. kostenlos), aber der Hörgenuss ist ein völlig anderer — es klingt wie ein echter Mensch, der dir den Kalender vorliest.

Die Fallback-Architektur sorgt dafür, dass die Ansage nie ausfällt. Selbst wenn ElevenLabs, DeepSeek oder das Ticketsystem gleichzeitig down sind — Google TTS und die deterministische Titel-Reinigung liefern immer noch einen brauchbaren Bericht. Der Lautsprecher wird nie schweigen.

Und falls du dich fragst: Ja, C-3PO schafft es auch, die Müllabfuhr-Ansage mit derselben Würde vorzutragen wie eine Sitzungsankündigung des Galaktischen Senats.