Das Problem

Morgens aufstehen und erstmal durch den Kalender scrollen, Wetter checken, Mülltonnen-Status prüfen — das ist wie Kaffee kochen ohne Kaffeemaschine. Geht, nervt aber.

Ich wollte: Morgens aus dem Bett rollen und alle wichtigen Infos per Sprache bekommen. Wetter, Termine, ob heute der Müll rausgestellt werden muss — alles automatisch, laut, klar.

TL;DR

Python-Script sammelt Termine (CalDAV von Grommunio auf srv-mail.rebellion.local), Wetter (Open-Meteo) und Müllabfuhr (Home Assistant Waste Collection auf srv-r2d2.deathstar.lan), packt alles in einen Text und spielt ihn per Piper TTS auf einem Yamaha MusicCast Speaker ab.

Herausforderung: Piper spricht nur Englisch, aber meine Termine sind auf Deutsch → Lösung: OpenAI API übersetzt Termin-Titel on-the-fly.

Bonus: Wiederkehrende Termine (RRULE) werden korrekt berechnet, Zeitformate werden sprechbar gemacht (“13:00” → “1 PM”).

Komponenten

  • Home Assistant auf srv-r2d2.deathstar.lan (192.168.66.42)
  • Piper TTS (tts.piper_2 Entity in HA)
  • Yamaha MusicCast Speaker (media_player.living_room)
  • CalDAV Kalender (Grommunio auf srv-mail.rebellion.local)
  • Open-Meteo API (kostenlose Wetter-API, kein API-Key nötig)
  • Waste Collection Schedule Integration (Home Assistant)
  • OpenAI API (für deutsche → englische Übersetzung)
  • Python 3 + Cron (für die tägliche Ausführung)

Die Diagnose

Wenn dein Morning Briefing nicht funktioniert, prüfe diese Punkte:

Test 1: Home Assistant API erreichbar?

curl -H "Authorization: Bearer YOUR_HA_TOKEN" \
  http://192.168.66.42:8123/api/states/media_player.living_room | jq .

Expected: JSON mit state: "on" oder state: "off"

Test 2: Piper TTS verfügbar?

curl -X POST -H "Authorization: Bearer YOUR_HA_TOKEN" \
  -H "Content-Type: application/json" \
  http://192.168.66.42:8123/api/services/tts/speak \
  -d '{"entity_id":"tts.piper_2","media_player_entity_id":"media_player.living_room","message":"Test"}'

Expected: Speaker spielt “Test”

Test 3: CalDAV-Zugriff funktioniert?

curl -u "username:password" \
  -H "Depth: 1" \
  -X PROPFIND https://srv-mail.rebellion.local/caldav/calendars/OWNER_NAME/default/

Expected: XML mit <D:multistatus>

Test 4: Logs prüfen wenn es nicht läuft

# Home Assistant Logs
journalctl -u homeassistant -f

# Cron Logs
tail -f /var/log/morning-tts.log

Die Herausforderungen

1. Piper spricht nur Englisch

Piper TTS in Home Assistant unterstützt mehrere Sprachen, aber die besten Stimmen sind englisch. Problem: Meine Kalender-Termine sind auf Deutsch.

Lösung: OpenAI API (gpt-4o-mini) übersetzt Termin-Titel on-the-fly von Deutsch nach Englisch.

def translate_to_english(text):
    """Translate German text to English via OpenAI API"""
    api_key = os.environ.get("OPENAI_API_KEY")
    if not api_key:
        return text  # Fallback: keep original

    req = urllib.request.Request(
        "https://api.openai.com/v1/chat/completions",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json"
        },
        data=json.dumps({
            "model": "gpt-4o-mini",
            "messages": [
                {"role": "system", "content": "Translate the following German calendar appointment title to English. Keep it short and natural. Only output the translation, nothing else."},
                {"role": "user", "content": text}
            ],
            "max_tokens": 50
        }).encode()
    )

    with urllib.request.urlopen(req, timeout=10) as resp:
        data = json.loads(resp.read().decode())
        return data["choices"][0]["message"]["content"].strip()

Beispiel:

  • Deutsch: “Meeting mit Kunden”
  • Englisch: “Meeting with customer”

Kostet fast nichts (gpt-4o-mini ist billig) und klingt natürlich.

2. Zeitformat für TTS: “13:00” wird zu “thirteen zero zero”

Das perfide: Piper spricht “13:00” als “thirteen zero zero” aus. Klingt wie ein Militär-Funkspruch von Stormtroopern, nicht wie ein Kalender. Menschen sagen “1 PM”, aber TTS-Engines interpretieren Zahlen literal.

Lösung: Zeitformat in menschliche Sprache konvertieren:

def format_time_spoken(time_str):
    """Convert 24h time to spoken English (e.g., 13:00 -> 1 PM)"""
    hour, minute = map(int, time_str.split(':'))

    # AM/PM
    period = "AM" if hour < 12 else "PM"

    # 12-hour format
    if hour == 0:
        hour = 12
    elif hour > 12:
        hour -= 12

    if minute == 0:
        return f"{hour} {period}"
    elif minute == 30:
        return f"{hour} thirty {period}"
    else:
        return f"{hour} {minute:02d} {period}"

Beispiele:

  • 13:00 → “1 PM”
  • 09:30 → “9 thirty AM”
  • 15:45 → “3 45 PM”

Klingt jetzt wie ein Mensch, nicht wie HAL 9000.

3. Wiederkehrende Termine (RRULE) berechnen

CalDAV-Termine können wiederkehrend sein (z.B. “Jeden Montag um 10 Uhr”). Das steht in der RRULE (Recurrence Rule).

Das perfide: Grommunio nutzt Timezone-Formate wie TZID="(GMT +01:00)" statt RFC 5545-Standard. Die icalendar-Library erwartet TZID=Europe/Berlin und bricht bei diesem Format. Resultat: Alle wiederkehrenden Termine verschwinden stillschweigend.

Lösung 1: Regex-Fallback-Parser für DTSTART/DTEND:

def _parse_ical_datetime(ical_text, property_name):
    """Parse DTSTART/DTEND from iCal text with weird timezone formats"""
    pattern = rf'{property_name}[^:]*?:(\d{{8}}(?:T\d{{6}})?(?:Z)?)'
    match = re.search(pattern, ical_text)

    if match:
        datetime_str = match.group(1)

        if 'T' not in datetime_str:
            # All-day event
            dt = datetime.strptime(datetime_str, '%Y%m%d')
            return dt.replace(tzinfo=timezone(timedelta(hours=1)))
        elif datetime_str.endswith('Z'):
            # UTC time
            dt = datetime.strptime(datetime_str, '%Y%m%dT%H%M%SZ')
            return dt.replace(tzinfo=timezone.utc)
        else:
            # Local time
            dt = datetime.strptime(datetime_str, '%Y%m%dT%H%M%S')
            return dt.replace(tzinfo=timezone(timedelta(hours=1)))

    return None

Lösung 2: RRULE mit dateutil.rrule berechnen:

from dateutil.rrule import rrulestr

# RRULE als String für dateutil
rrule_str = component.get('RRULE').to_ical().decode('utf-8')

# DTSTART als naive datetime für rrule
dtstart_dt = datetime.combine(dt_val, datetime.min.time())

# Parse RRULE
recur = rrulestr(rrule_str, dtstart=dtstart_dt, ignoretz=True)

# Berechne ob heute eine Instanz stattfindet
today_start = datetime.combine(today_date, datetime.min.time())
today_end = datetime.combine(today_date, datetime.max.time())

instances_today = list(recur.between(today_start, today_end, inc=True))

Jetzt werden wiederkehrende Termine korrekt erkannt und angesagt.

Die Lösung: Zwei Python-Scripts + Cron

Script 1: termin-ansage.py — CalDAV-Termine abrufen

Holt Termine vom Grommunio CalDAV-Server (srv-mail.rebellion.local), parsed VEVENT-Komponenten, berechnet RRULE-Instanzen für heute.

Features:

  • CalDAV REPORT Query mit Zeit-Range für heute
  • Fallback-Parsing für kaputte iCal-Formate (siehe Grommunio TZID-Problem oben)
  • RRULE-Support für wiederkehrende Termine
  • Geburtstags-Erkennung (Keywords: “Geburtstag”, “Birthday”)
  • Nur Zeitblock-Termine (keine ganztägigen Events, außer mit --all-day)

Aufruf:

python3 termin-ansage.py --dry-run

Output:

🔍 Rufe Kalender ab von srv-mail.rebellion.local...
📅 3 Termine gesamt gefunden
   └─ 2 Zeitblock-Termine
      13:00-14:00: Meeting mit Kunden
      16:00-17:00: Team Sync
   └─ 0 Ganztägige Events (Standard: nicht vorlesen)
   └─ 1 Geburtstage
      🎂 Colleague X

🎙️ Generiere TTS-Text...
Text: Du hast heute 2 Termine. Um 13:00 Uhr bis 14:00 Uhr: Meeting mit Kunden. Um 16:00 Uhr bis 17:00 Uhr: Team Sync. Außerdem: Colleague X's Geburtstag.

Script 2: morning-tts.py — Alles kombinieren und vorlesen

Sammelt:

  • Wetter (Open-Meteo API, keine Auth nötig)
  • Müllabfuhr (Home Assistant Waste Collection Sensoren)
  • Termine (ruft termin-ansage.py --dry-run auf, parsed die Ausgabe)

Übersetzt deutsche Termin-Titel nach Englisch (OpenAI API), formatiert Zeiten für TTS, spielt das Ergebnis auf dem Yamaha MusicCast Speaker ab.

Aufruf:

export OPENAI_API_KEY="sk-..."
python3 morning-tts.py

Output:

TTS: Good morning! Weather: Currently 3 degrees, partly cloudy. Today between -1 and 5 degrees. Garbage collection: organic waste today. You have 2 appointments today. At 1 PM: Meeting with customer. At 4 PM: Team Sync.
✅ TTS sent to media_player.living_room (192.168.66.42)

Cron: Täglich um 7:30 Uhr

crontab -e

Eintrag:

30 7 * * * export OPENAI_API_KEY="sk-..." && /usr/bin/python3 /root/clawd/scripts/morning-tts.py >> /var/log/morning-tts.log 2>&1

Wichtig: OpenAI API-Key als Environment Variable setzen (oder in einer .env-Datei speichern und mit source laden).

Code-Snippets

Wetter von Open-Meteo holen

def get_weather():
    """Get weather from Open-Meteo"""
    url = f"https://api.open-meteo.com/v1/forecast?latitude={LAT}&longitude={LON}&current=temperature_2m,weather_code&daily=temperature_2m_max,temperature_2m_min,precipitation_probability_max&timezone=Europe/Berlin&forecast_days=1"

    with urllib.request.urlopen(url, timeout=10) as resp:
        data = json.loads(resp.read().decode())

        current_temp = data["current"]["temperature_2m"]
        weather_code = data["current"]["weather_code"]
        max_temp = data["daily"]["temperature_2m_max"][0]
        min_temp = data["daily"]["temperature_2m_min"][0]
        rain_prob = data["daily"]["precipitation_probability_max"][0]

        weather_text = {
            0: "clear sky", 1: "mostly clear", 2: "partly cloudy",
            61: "light rain", 63: "rain", 65: "heavy rain",
            95: "thunderstorm"
        }.get(weather_code, "unknown")

        return {
            "current": round(current_temp),
            "max": round(max_temp),
            "min": round(min_temp),
            "rain_prob": rain_prob,
            "text": weather_text
        }

Müllabfuhr-Status aus Home Assistant

def get_garbage():
    """Get garbage collection status from HA on srv-r2d2.deathstar.lan"""
    garbage_types = {
        "sensor.biomull": "organic waste",
        "sensor.pappe_papier": "paper and cardboard",
        "sensor.hausmull": "household waste",
        "sensor.leichtstoffverpackungen": "recycling"
    }

    today_tomorrow = []
    for sensor, name in garbage_types.items():
        state = ha_api(f"/api/states/{sensor}")
        if state:
            val = state.get("state", "").lower()
            if "heute" in val or "today" in val:
                today_tomorrow.append(f"{name} today")
            elif "morgen" in val or "tomorrow" in val:
                today_tomorrow.append(f"{name} tomorrow")

    return today_tomorrow

TTS auf Yamaha MusicCast abspielen

def speak(text):
    """Send TTS to Yamaha via Piper on srv-r2d2.deathstar.lan (192.168.66.42)"""
    # Turn on Yamaha
    ha_api("/api/services/media_player/turn_on", "POST", {"entity_id": MEDIA_PLAYER})

    time.sleep(2)

    # Play TTS via Piper
    ha_api("/api/services/tts/speak", "POST", {
        "entity_id": "tts.piper_2",
        "media_player_entity_id": "media_player.living_room",
        "message": text
    })

Verifikation

# Dry-Run: Nur Text anzeigen, nicht abspielen
python3 morning-tts.py --dry-run

# Echter Durchlauf
python3 morning-tts.py

Wenn der Yamaha-Speaker angeht und die Ansage läuft, hat’s geklappt.

Lessons Learned

1. Piper TTS ist schnell, aber englischsprachig

Wenn du deutsche Inhalte vorlesen willst, brauchst du Übersetzung. OpenAI API ist günstig und liefert gute Ergebnisse.

Alternative: Google Translate TTS (tts.google_translate_say) — kostenlos, aber langsamer und nicht offline.

2. Zeit-Formate für TTS anpassen

“13:00” klingt für TTS wie “thirteen zero zero”. Menschen sagen “1 PM”. Konvertiere Zeiten immer in natürliche Sprache.

3. RRULE-Parsing ist tricky

Die icalendar-Library ist gut, aber nicht perfekt. Timezone-Formate wie TZID="(GMT +01:00)" bringen sie durcheinander. Regex-Fallback rettet dich wenn die Library versagt.

4. Open-Meteo ist Gold wert

Kostenlose Wetter-API, kein API-Key, kein Rate Limit, GDPR-konform (Server in der EU). Perfekt für Homelab-Projekte.

API-Docs: https://open-meteo.com/en/docs

5. CalDAV REPORT Query ist mächtiger als GET

Statt alle Termine zu holen und lokal zu filtern, kannst du direkt eine Zeit-Range mitgeben:

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

Spart Bandbreite und Rechenzeit.

6. Grommunio TZID-Format ist non-standard

Grommunio nutzt TZID="(GMT +01:00)" statt RFC 5545 (TZID=Europe/Berlin). Wenn deine wiederkehrenden Termine nicht auftauchen, prüfe das TZID-Format mit:

curl -u "user:pass" https://srv-mail.rebellion.local/caldav/calendars/OWNER_NAME/default/event.ics | grep TZID

Falls du TZID="(GMT +01:00)" siehst, brauchst du einen Regex-Parser (siehe “Lösung 1” oben).

Fazit

Morning Briefings per TTS sind wie ein persönlicher Assistent, der nie verschläft.

Das Setup funktioniert seit Wochen zuverlässig. Morgens aufstehen, Kaffee machen, dabei hören was heute ansteht. Kein Handy-Geglotze, kein Kalender-Scrollen.

Was als nächstes?

  • Nachrichten-Integration: RSS-Feeds oder Heise News vorlesen
  • Verkehrslage: Google Maps API für Pendler-Route
  • Reminder: “Du hast in 10 Minuten einen Termin” (HA Automation)

Referenzen