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_2Entity 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-runauf, 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}¤t=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)