Hello everyone,
I wanted to return the favor here in the forum and create a how-to guide. So far I hadn’t found much about it on the internet, so I set out to find out how to bring TTS announcements to Homepods.
A quick look at my setup: I have openhabian running on a PI 5 (8GB) and specifically openHAB 4.3.2. I also have a PI4 (4GB) that only runs Zigbee2MQTT - so it’s not relevant here - and a QNAP NAS TS-451+ (16GB RAM) on which the Container Station app is installed, which you can use to manage Docker.
There are also, of course, several Homepods spread around the house (and of course various other smart home devices that are only marginally relevant here as triggers, etc.).
What are the relevant components on the software side:
- openhab - obviously as a basis
- Pico TTS - any other TTS service that can generate WAV or MP3 will certainly work
- owntone - to bring music files or radio stations to the Homepods via Airplay or to set the volume or start/stop playback
Step by step, how did I proceed:
Owntone
I started by creating a dedicated user and the folders required for Owntone on my NAS. Owntone basically needs a main folder and three subfolders:
- Cache
- etc
- media
The user must have read and write permissions for all of them.
Then it continues with the installation of the official Docker image - make sure that you enter the correct user and the correct folder paths as well as a fixed IP for Owntone.
You can then access the Owntone WEB UI using the correct IP and port and test playing MP3 files on the Homepods that you put in the media folder. Important note that almost drove me to despair - in the Apple Home app, under Speakers & TV, you have to allow everyone on the same network or everyone to have access. I had Only people in this home activated and that always kicks out Owntone. Once you’ve done that, Owntone is ready.
You could certainly vary this and install Owntone on the same device as Openhab, but I had the NAS so why not use it - it saves the PI’s resources.
Next, I mounted the media folder on the NAS to the PI:
sudo mkdir -p /mnt/nas_owntone_media
fstab edited:
//192.168.1.103/owntone/media /mnt/nas_owntone_media cifs username=xxx,password=xxx,vers=3.0,uid=openhab,gid=openhab 0 0
and of course
sudo mount -a
sudo systemctl daemon-reload
and for testing
ls /mnt/nas_owntone_media
If all this worked, go to the next step.
PICO TTS
As mentioned at the beginning, any other TTS service that can generate WAV or MP3 will work here - I chose PICO because it is lightweight, no subscription or cloud service, so it still works even if there is no internet connection.
So the necessary installations on the PI:
sudo apt install libttspico-utils
And then of course install the PICO add-on via the Openhab web UI and select PICO as the default TTS and enter the preferred voice, as well as activate caching. If you have any questions, please let me know.
If all this works, all preparations are complete on the software side and we can continue with the rules.
Rules
Important information: I have an item to which I send all messages that should be displayed or sent, and this also goes to Fire tablets via their TTS function, so I use URL encoding in the first rule shown and URL decoding again in the second, which then goes to PICO. If you only use Homepods, you can skip all of this!
rule "tts-2"
// Nachricht encodieren, zur Queue hinzufügen und zur Verarbeitung übergeben
when
Item TabletTTSMessage received update
then
val newMessage = TabletTTSMessage.state.toString
// Sicherstellen, dass die Nachricht nicht leer ist
if (newMessage !== null && newMessage.trim != "") {
// Nachricht encodieren
val encodedMessage = newMessage.trim
.replace(" ", "+")
.replace("ä", "%C3%A4")
.replace("ö", "%C3%B6")
.replace("ü", "%C3%BC")
.replace("Ä", "%C3%84")
.replace("Ö", "%C3%96")
.replace("Ü", "%C3%9C")
.replace("ß", "%C3%9F")
// Aktuelle Queue abrufen
val currentQueue = if (TTSQueue.state != NULL) TTSQueue.state.toString else ""
val updatedQueue = if (currentQueue == "") encodedMessage else currentQueue + "|" + encodedMessage
// Aktualisierte Queue speichern
TTSQueue.sendCommand(updatedQueue)
if (Advanced_Logging.state == ON) {
logInfo("TTS", "Neue encodierte Nachricht hinzugefügt: " + encodedMessage)
logInfo("TTS", "Aktuelle Queue: " + updatedQueue)
}
// Wenn keine weitere Verarbeitung läuft, die erste Nachricht übernehmen
if (ttsTimer === null) {
ttsTimer = createTimer(now.plusNanos(500000000), [ |
if (TTSQueue.state != NULL && TTSQueue.state.toString != "") {
val messages = TTSQueue.state.toString.split("\\|")
val nextMessage = messages.get(0)
// Entferne die verarbeitete Nachricht aus der Queue
val remainingQueue = if (messages.size > 1) TTSQueue.state.toString.substring(TTSQueue.state.toString.indexOf("|") + 1) else ""
TTSQueue.sendCommand(remainingQueue)
if (Advanced_Logging.state == ON) {
logInfo("TTS", "Übergebe Nachricht zur Verarbeitung: " + nextMessage)
logInfo("TTS", "Queue nach Übergabe: " + remainingQueue)
}
// Übergabe der Nachricht an das temporäre Item
TabletTTSOutgoingMessage.postUpdate(nextMessage)
// Timer zurücksetzen, wenn weitere Nachrichten verarbeitet werden müssen
ttsTimer = if (remainingQueue != "") createTimer(now.plusSeconds(25), [ | ttsTimer = null ]) else null
} else {
ttsTimer = null
}
])
}
} else {
// Loggen, dass die empfangene Nachricht leer war
if (Advanced_Logging.state == ON) {
logInfo("TTS", "Leere oder ungültige Nachricht empfangen, keine Verarbeitung.")
}
}
end
here is the second rule which then sends the message to Pico and then switches an item to off as soon as the new WAV file is ready:
rule "tts-1"
when
Item TabletTTSOutgoingMessage received update
then
// Den Text aus dem String Item "TabletTTSOutgoingMessage" abrufen und "+" durch Leerzeichen ersetzen
val ttsText = TabletTTSOutgoingMessage.state.toString
.replace("+", " ")
.replace("%C3%A4", "ä")
.replace("%C3%B6", "ö")
.replace("%C3%BC", "ü")
.replace("%C3%84", "Ä")
.replace("%C3%96", "Ö")
.replace("%C3%9C", "Ü")
.replace("%C3%9F", "ß")
val outputPath = "/mnt/nas_owntone_media/tts-output.wav"
// Setze das Switch-Item tts_datei_erstellung auf ON, um den Status zu zeigen
sendCommand(tts_datei_erstellung, ON)
val command = newArrayList(
"/usr/bin/pico2wave",
"-l",
"de-DE",
"-w",
outputPath,
ttsText
)
if (Advanced_Logging.state == ON) {
logInfo("TTS", "Running command: " + command.toString())
}
try {
val result = Exec.executeCommandLine(Duration.ofSeconds(5), command)
if (Advanced_Logging.state == ON) {
logInfo("TTS", "Command executed successfully. Result: " + result)
}
} catch (Exception e) {
if (Advanced_Logging.state == ON) {
logError("TTS", "Error executing command: " + e.toString())
}
}
// Überprüfe das Änderungsdatum der Datei
var fileCreated = false
var retries = 0
while (!fileCreated && retries < 10) { // Maximale Versuche, 10-mal prüfen
if (Files.exists(Paths.get(outputPath))) {
try {
// Hole das Änderungsdatum der Datei
val lastModifiedTime = Files.getLastModifiedTime(Paths.get(outputPath)) as FileTime
if (Advanced_Logging.state == ON) {
logInfo("TTS", "Last modified time: " + lastModifiedTime.toString()) // Logge das lastModifiedTime
}
// Konvertiere FileTime zu Instant
val lastModifiedInstant = lastModifiedTime.toInstant()
if (Advanced_Logging.state == ON) {
logInfo("TTS", "Last modified instant: " + lastModifiedInstant.toString()) // Logge das Instant
}
// Vergleiche das Änderungsdatum mit der aktuellen Zeit
if (Instant.now().minusSeconds(10).isBefore(lastModifiedInstant)) {
fileCreated = true
}
} catch (Exception e) {
logError("TTS", "Error getting or parsing last modified time: " + e.toString())
}
}
if (!fileCreated) {
Thread::sleep(500) // 500 ms warten, bevor erneut geprüft wird
retries = retries + 1
}
}
// Wenn die Datei erfolgreich erstellt wurde, setze das Switch-Item auf OFF
if (fileCreated) {
if (Advanced_Logging.state == ON) {
logInfo("TTS", "WAV file generated at: " + outputPath)
}
sendCommand(tts_datei_erstellung, OFF) // Schalte das Switch-Item auf OFF
} else {
logError("TTS", "WAV file not created after retries.")
sendCommand(tts_datei_erstellung, OFF) // Schalte das Switch-Item auf OFF bei Fehler
}
end
I always give the wav file the same name, then the ID in Owntone does not change and I do not have to monitor the folder.
In the next step you just have to find out which ID the file you want to play has in Owntone. In my case, I also play an individual doorbell sound when the doorbell rings.
Or a radio in certain rooms at the touch of a button:
(http://192.168.1.19:3689/api/library/files?directory=/srv/media)
…19 is the ip of my Owntone Server
This command gives you a list of track-IDs.
And this gives you your output device ids:
(http://192.168.1.19:3689/api/outputs)
Then you can move on to the rules for the Homepods.
HomePod Rules
var Timer doorbell_timer = null
var Timer homepod_operation_timer = null
var Boolean radio_state_changed = false
// Hilfsfunktion für Timer-Check
val isRadioOperationInProgress = [ |
homepod_operation_timer !== null
]
rule "homepod-1"
when
Item Radio_Bad changed or
Item Radio_Freisitz changed or
Item Radio_Kueche changed
then
if (!isRadioOperationInProgress.apply()) {
homepod_operation_timer = createTimer(now.plusSeconds(5), [|
homepod_operation_timer = null
])
val baseUrl = "http://192.168.1.19:3689/api/outputs/"
val radioUrl = "library:playlist:7"
val podMap = newHashMap(
"Radio_Bad" -> newArrayList("245003738814281"),
"Radio_Freisitz" -> newArrayList("261851581089131", "117162080021876"),
"Radio_Kueche" -> newArrayList("156215867171413")
)
val activePods = newArrayList()
podMap.forEach[ key, id |
if (Radio_Bad.state == ON && key == "Radio_Bad") activePods.addAll(id)
if (Radio_Freisitz.state == ON && key == "Radio_Freisitz") activePods.addAll(id)
if (Radio_Kueche.state == ON && key == "Radio_Kueche") activePods.addAll(id)
]
if (!activePods.isEmpty) {
// Aktivierung der HomePods
val jsonActivate = '{"outputs":' + activePods.toString + '}'
if (Advanced_Logging.state == ON) {
logInfo("HomePod", "Sende Aktivierungsanfrage: " + jsonActivate)
}
sendHttpPutRequest(baseUrl + "set", "application/json", jsonActivate, 30000)
// Lautstärke für jeden HomePod einzeln setzen
val jsonVolume = '{"volume": 30}'
for (podId : activePods) {
val responseVolume = sendHttpPutRequest(baseUrl + podId, "application/json", jsonVolume, 30000)
if (responseVolume.contains("error")) {
logError("HomePod", "Fehler beim Setzen der Lautstärke für " + podId + ": " + responseVolume)
} else {
if (Advanced_Logging.state == ON) {
logInfo("HomePod", "Lautstärke für " + podId + " auf 30 gesetzt.")
}
}
}
sendHttpPutRequest("http://192.168.1.19:3689/api/queue/clear", "application/json", "", 30000)
// Playlist zur Queue hinzufügen
val queueResponse = sendHttpPostRequest(
"http://192.168.1.19:3689/api/queue/items/add?uris=" + radioUrl + "&clear=true",
"application/json",
"",
30000
)
if (Advanced_Logging.state == ON) {
logInfo("HomePod", "Queue Response: " + queueResponse)
}
// Player starten
val playerResponse = sendHttpPutRequest("http://192.168.1.19:3689/api/player/play",
"application/json",
"",
30000)
if (Advanced_Logging.state == ON) {
logInfo("HomePod", "Player Response: " + playerResponse)
logInfo("HomePod", "Absolut TOP gestartet auf: " + activePods.toString)
}
} else {
sendHttpPutRequest("http://192.168.1.19:3689/api/player/stop", "application/json", "", 30000)
if (Advanced_Logging.state == ON) {
logInfo("HomePod", "Kein HomePod aktiv, Radio gestoppt.")
}
}
} else {
logInfo("HomePod", "Radio-Operation läuft bereits")
radio_state_changed = true
createTimer(now.plusSeconds(6), [|
if(radio_state_changed && homepod_operation_timer === null) {
radio_state_changed = false
// Alle Radio-Items neu triggern
Radio_Bad.sendCommand(Radio_Bad.state)
Radio_Kueche.sendCommand(Radio_Kueche.state)
Radio_Freisitz.sendCommand(Radio_Freisitz.state)
}
])
}
end
rule "homepod-2"
when
Item tts_datei_erstellung changed to OFF
then
if (!isRadioOperationInProgress.apply()) {
logInfo("HomePod", "Keine Radio-Operation läuft")
homepod_operation_timer = createTimer(now.plusSeconds(25), [|
homepod_operation_timer = null
])
val baseUrl = "http://192.168.1.19:3689/api/outputs/"
val ttsFile = "library:track:4"
val radioUrl = "library:playlist:7"
val podMap = newHashMap(
"Homepod_SZ_TTS" -> newArrayList("222742452495664"),
"Homepod_Bad_TTS" -> newArrayList("245003738814281"),
"Homepod_WZ_TTS" -> newArrayList("143733425724839"),
"Homepod_Terrasse_Freisitz_TTS" -> newArrayList("261851581089131", "117162080021876"),
"Homepod_Kueche_TTS" -> newArrayList("156215867171413")
)
val activePods = newArrayList()
// Direkte Prüfung der TTS-Items
if (Homepod_SZ_TTS.state == ON) activePods.addAll(podMap.get("Homepod_SZ_TTS"))
if (Homepod_Bad_TTS.state == ON) activePods.addAll(podMap.get("Homepod_Bad_TTS"))
if (Homepod_WZ_TTS.state == ON) activePods.addAll(podMap.get("Homepod_WZ_TTS"))
if (Homepod_Terrasse_Freisitz_TTS.state == ON) activePods.addAll(podMap.get("Homepod_Terrasse_Freisitz_TTS"))
if (Homepod_Kueche_TTS.state == ON) activePods.addAll(podMap.get("Homepod_Kueche_TTS"))
if (Advanced_Logging.state == ON) {
logInfo("HomePod", "Aktive Pods: " + activePods.toString)
}
if (!activePods.isEmpty) {
// Zuerst Player stoppen
sendHttpPutRequest("http://192.168.1.19:3689/api/player/stop", "application/json", "", 30000)
Thread::sleep(500)
// Aktivierung der HomePods
val jsonActivate = '{"outputs":' + activePods.toString + '}'
if (Advanced_Logging.state == ON) {
logInfo("HomePod", "Sende Aktivierungsanfrage: " + jsonActivate)
}
sendHttpPutRequest(baseUrl + "set", "application/json", jsonActivate, 30000)
// Lautstärke für jeden HomePod einzeln setzen
val jsonVolume = '{"volume": 70}' // Angepasst auf 70 für TTS
for (podId : activePods) {
val responseVolume = sendHttpPutRequest(baseUrl + podId, "application/json", jsonVolume, 30000)
if (responseVolume.contains("error")) {
logError("HomePod", "Fehler beim Setzen der Lautstärke für " + podId + ": " + responseVolume)
} else {
if (Advanced_Logging.state == ON) {
logInfo("HomePod", "Lautstärke für " + podId + " auf 70 gesetzt.")
}
}
}
// Queue leeren und TTS abspielen
sendHttpPutRequest("http://192.168.1.19:3689/api/queue/clear", "application/json", "", 30000)
val queueResponse = sendHttpPostRequest(
"http://192.168.1.19:3689/api/queue/items/add?uris=" + ttsFile + "&clear=true",
"application/json",
"",
30000
)
if (Advanced_Logging.state == ON) {
logInfo("HomePod", "Queue Response: " + queueResponse)
}
sendHttpPutRequest("http://192.168.1.19:3689/api/player/play", "application/json", "", 30000)
createTimer(now.plusSeconds(20), [ |
// Zuerst Player stoppen
sendHttpPutRequest("http://192.168.1.19:3689/api/player/stop", "application/json", "", 30000)
Thread::sleep(500)
val previousActivePods = newArrayList()
if (Radio_Bad.state == ON) previousActivePods.add("245003738814281")
if (Radio_Freisitz.state == ON) previousActivePods.addAll(newArrayList("261851581089131", "117162080021876"))
if (Radio_Kueche.state == ON) previousActivePods.add("156215867171413")
if (!previousActivePods.empty) {
// Reaktivierung der vorherigen HomePods mit Lautstärke 30
val jsonReactivate = '{"outputs":' + previousActivePods.toString + '}'
sendHttpPutRequest(baseUrl + "set", "application/json", jsonReactivate, 30000)
val jsonVolumeReactivate = '{"volume": 30}'
for (podId : previousActivePods) {
val responseVolumeReactivate = sendHttpPutRequest(baseUrl + podId, "application/json", jsonVolumeReactivate, 30000)
if (responseVolumeReactivate.contains("error")) {
logError("HomePod", "Fehler beim Setzen der Lautstärke für " + podId + ": " + responseVolumeReactivate)
} else {
if (Advanced_Logging.state == ON) {
logInfo("HomePod", "Lautstärke für " + podId + " auf 30 gesetzt.")
}
}
}
// Radio nur starten wenn aktive Pods vorhanden
sendHttpPutRequest("http://192.168.1.19:3689/api/queue/clear", "application/json", "", 30000)
sendHttpPostRequest(
"http://192.168.1.19:3689/api/queue/items/add?uris=" + radioUrl + "&clear=true",
"application/json",
"",
30000
)
sendHttpPutRequest("http://192.168.1.19:3689/api/player/play", "application/json", "", 30000)
if (Advanced_Logging.state == ON) {
logInfo("HomePod", "Radio wiederhergestellt für aktive Pods: " + previousActivePods.toString)
}
} else {
if (Advanced_Logging.state == ON) {
logInfo("HomePod", "Keine aktiven Radio-HomePods gefunden, überspringe Radio-Wiederherstellung")
}
}
])
if (Advanced_Logging.state == ON) {
logInfo("HomePod", "TTS abgespielt auf: " + activePods.toString)
}
}
} else {
logInfo("HomePod", "Operation läuft: " + isRadioOperationInProgress.apply())
}
// TTS-Items immer zurücksetzen, unabhängig von Operation
Homepod_SZ_TTS.sendCommand(OFF)
Homepod_Bad_TTS.sendCommand(OFF)
Homepod_WZ_TTS.sendCommand(OFF)
Homepod_Terrasse_Freisitz_TTS.sendCommand(OFF)
Homepod_Kueche_TTS.sendCommand(OFF)
end
rule "homepod-3"
when
Item Doorbell changed to ON
then
if (!isRadioOperationInProgress.apply()) {
homepod_operation_timer = createTimer(now.plusSeconds(15), [|
homepod_operation_timer = null
])
val baseUrl = "http://192.168.1.19:3689/api/outputs/"
val doorbellFile = "library:track:6"
val radioUrl = "library:playlist:7"
val allPods = newArrayList("245003738814281", "261851581089131", "117162080021876", "156215867171413", "222742452495664", "143733425724839")
// Prüfen ob Timer bereits läuft
if (doorbell_timer === null) {
doorbell_timer = createTimer(now.plusSeconds(30), [|
doorbell_timer = null
])
try {
// Zuerst Player stoppen
sendHttpPutRequest("http://192.168.1.19:3689/api/player/stop", "application/json", "", 30000)
Thread::sleep(500)
// Aktivierung aller HomePods
val jsonActivate = '{"outputs":' + allPods.toString + '}'
if (Advanced_Logging.state == ON) {
logInfo("HomePod", "Sende Aktivierungsanfrage: " + jsonActivate)
}
val activateResponse = sendHttpPutRequest(baseUrl + "set", "application/json", jsonActivate, 10000)
if (activateResponse !== null) {
// Lautstärke setzen
val jsonVolume = '{"volume": 50}'
for (podId : allPods) {
val responseVolume = sendHttpPutRequest(baseUrl + podId, "application/json", jsonVolume, 10000)
if (responseVolume !== null && responseVolume.contains("error")) {
logError("HomePod", "Fehler beim Setzen der Lautstärke für " + podId)
}
}
// Queue leeren und Türklingel abspielen
sendHttpPutRequest("http://192.168.1.19:3689/api/queue/clear", "application/json", "", 10000)
Thread::sleep(500)
val queueResponse = sendHttpPostRequest(
"http://192.168.1.19:3689/api/queue/items/add?uris=" + doorbellFile + "&clear=true",
"application/json",
"",
10000
)
if (queueResponse !== null) {
sendHttpPutRequest("http://192.168.1.19:3689/api/player/play", "application/json", "", 10000)
// Timer für Radio-Wiederherstellung
createTimer(now.plusSeconds(10), [ |
// Zuerst Player stoppen
sendHttpPutRequest("http://192.168.1.19:3689/api/player/stop", "application/json", "", 30000)
Thread::sleep(500)
// Aktuelle Radio-Status prüfen
val currentActivePods = newArrayList()
if (Radio_Bad.state == ON) currentActivePods.add("245003738814281")
if (Radio_Freisitz.state == ON) currentActivePods.addAll(newArrayList("261851581089131", "117162080021876"))
if (Radio_Kueche.state == ON) currentActivePods.add("156215867171413")
if (!currentActivePods.empty) {
// Reaktivierung der vorherigen HomePods
val jsonReactivate = '{"outputs":' + currentActivePods.toString + '}'
sendHttpPutRequest(baseUrl + "set", "application/json", jsonReactivate, 30000)
// Lautstärke wieder auf Standard setzen
val jsonVolumeReactivate = '{"volume": 30}'
for (podId : currentActivePods) {
val responseVolumeReactivate = sendHttpPutRequest(baseUrl + podId, "application/json", jsonVolumeReactivate, 30000)
if (responseVolumeReactivate !== null && responseVolumeReactivate.contains("error")) {
logError("HomePod", "Fehler beim Setzen der Lautstärke für " + podId)
}
}
// Radio neu starten
sendHttpPutRequest("http://192.168.1.19:3689/api/queue/clear", "application/json", "", 30000)
sendHttpPostRequest(
"http://192.168.1.19:3689/api/queue/items/add?uris=" + radioUrl + "&clear=true",
"application/json",
"",
30000
)
sendHttpPutRequest("http://192.168.1.19:3689/api/player/play", "application/json", "", 30000)
if (Advanced_Logging.state == ON) {
logInfo("HomePod", "Radio wiederhergestellt auf: " + currentActivePods.toString)
}
}
])
}
}
} catch (Throwable t) {
logError("HomePod", "Fehler beim Abspielen der Türklingel: " + t.message)
}
} else {
logInfo("HomePod", "Türklingel bereits aktiv, ignoriere Anfrage")
}
} else {
logInfo("HomePod", "Operation läuft bereits, überspringe Türklingel")
}
end
and now the radio playback as well as the playback of TTS announcements and also an individual doorbell sound are working.
I will gradually revise this how-to, so please let me know where I need to go into more detail and whether you are interested.
Talking about the input lag if relevant it takes about 3-5 seconds after a trigger starts the first rule until you hear the message. Doorbell reacts a little bit faster cause it is a prerecorded file.
But for me it works totally fine.