Guide how to setup TTS on HomePods, including Multiroom Audio

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.

4 Likes

Thanks for posting! I’ve moved this to Tutorials and Solutions so it’s easier to find. It’s nice to move something into that category for a change.

2 Likes

For those of you who do not like the sound pico creates (my wife) I changed the part were the file creation only works via pico and added voice rss - but as a fallback pico is still there:

if (Internet_Connection.state == ON) {
            // Voice RSS verwenden
            val voiceRssKey = "apikey"
            val voiceRssUrl = "https://api.voicerss.org/?" +
                "key=" + voiceRssKey +
                "&hl=de-de" +
                "&v=Hanna" +
                "&c=WAV" +
                "&f=16khz_16bit_mono" +
                "&src=" + URLEncoder.encode(ttsText, "UTF-8").toString

            try {
                val voiceCommand = newArrayList(
                    "curl",
                    "-o",
                    outputPath,
                    voiceRssUrl
                )
                executeCommandLine(Duration.ofSeconds(30), voiceCommand)
                if (Advanced_Logging.state == ON) {
                    logInfo("TTS", "Voice RSS Befehl ausgeführt")
                }
            } catch (Exception e) {
                logError("TTS", "Voice RSS Fehler - Fallback zu Pico2Wave")
                val picoCommand = newArrayList(
                    "/usr/bin/pico2wave",
                    "-l",
                    "de-DE",
                    "-w",
                    outputPath,
                    ttsText
                )
                executeCommandLine(Duration.ofSeconds(5), picoCommand)
                if (Advanced_Logging.state == ON) {
                    logInfo("TTS", "Pico2Wave Fallback ausgeführt")
                }
            }
        } else {
            val picoCommand = newArrayList(
                "/usr/bin/pico2wave",
                "-l",
                "de-DE",
                "-w",
                outputPath,
                ttsText
            )
            executeCommandLine(Duration.ofSeconds(5), picoCommand)
            if (Advanced_Logging.state == ON) {
                logInfo("TTS", "Pico2Wave ausgeführt")
            }
        }

When no internet connection (I ping Google) or error file creation falls back to pico.

Hope it helps - have fun.

Is there a Wakeword detection by the home pod? Is it offline the wake word detection?

You mean to activate Radio via Siri? I made those Radio Items availible to the homekit addon.

I have now started to make the rule even more robust and to translate it into JS, starting with controlling the Homepods via Owntone:

const { rules, items, time, actions } = require('openhab');

// Globale Timer und Variablen
let homepod_operation_timer = null;
let doorbell_timer = null;
let radio_state_changed = false;

// Konstanten
const OWNTONE_BASE_URL = "http://192.168.1.19:3689/api";
const POD_MAP = {
    Radio_Bad: ["245003738814281"],
    Radio_Freisitz: ["261851581089131", "117162080021876"],
    Radio_Kueche: ["156215867171413"],
    Radio_WZ: ["143733425724839"],
    Radio_SZ: ["222742452495664"]
};

// Prioritäten-Definition
const PRIORITIES = {
    NONE: 0,
    VOLUME: 1,
    SENDER: 2,
    CONTROL: 3,
    TTS: 4,
    DOORBELL: 5
};

let currentPriority = PRIORITIES.NONE;

// Hilfsfunktionen
const getPriority = () => currentPriority;
const setPriority = (newPriority) => {
    if (newPriority >= currentPriority) {
        currentPriority = newPriority;
    }
};
const resetPriority = (oldPriority) => {
    if (currentPriority === oldPriority) {
        currentPriority = PRIORITIES.NONE;
    }
};

// Timer Monitor Setup
const monitorOperation = () => {
    const isOperationInProgress = homepod_operation_timer !== null;
    items.getItem('Radio_Operation_Status').postUpdate(isOperationInProgress ? 'ON' : 'OFF');
};

// Hilfsfunktionen
const isRadioOperationInProgress = () => homepod_operation_timer !== null;

const getRadioUrl = () => {
    const sender = items.getItem('Aktueller_Radiosender').state;
    switch(sender) {
        case "Rock_Antenne": return "library:playlist:15";
        case "Antenne_Bayern": return "library:playlist:8";
        case "Bayern_3": return "library:playlist:14";
        case "Bayern_1": return "library:playlist:12";
        default: return "library:playlist:7";
    }
};

const setOperationTimer = (seconds) => {
    homepod_operation_timer = setTimeout(() => {
        homepod_operation_timer = null;
        monitorOperation();
    }, seconds * 1000);
    monitorOperation();
};

const checkServerStatus = () => {
    if (items.getItem('Owntone_Server_Status').state === 'Offline') {
        actions.NotificationAction.sendNotification(
            "andreasprobst88@icloud.com",
            "🔈 Owntone Server ist offline - Radio nicht möglich",
            "radio", "HomePod radio Tag", "Da Hoam", "owntone-radio-offline-1"
        );
        actions.NotificationAction.sendNotification(
            "manuela-probst@gmx.de",
            "🔈 Owntone Server ist offline - Radio nicht möglich",
            "radio", "HomePod radio Tag", "Da Hoam", "owntone-radio-offline-1"
        );
        return false;
    }
    return true;
};

const sendRequest = async (method, url, contentType, body = "") => {
    try {
        if (method === 'PUT') {
            return await actions.HTTP.sendHttpPutRequest(url, contentType, body, 30000);
        } else if (method === 'POST') {
            return await actions.HTTP.sendHttpPostRequest(url, contentType, body, 30000);
        }
    } catch (error) {
        console.error(`HTTP ${method} Request Error: ${error}`);
        throw error;
    }
};

// Hilfsfunktion für Player-Status
const getPlayerStatus = async () => {
    try {
        const headers = {'Content-Type': 'application/json'};
        const response = await actions.HTTP.sendHttpGetRequest(
            `${OWNTONE_BASE_URL}/player`,
            headers,
            30000
        );
        return JSON.parse(response).state;
    } catch (error) {
        console.error(`Player Status Error: ${error}`);
        return null;
    }
};

const getTTSVolume = () => {
    const hour = time.ZonedDateTime.now().hour;
    return hour < 9 ? 50 : 60;
};

const advancedLog = (message) => {
    if (items.getItem('Advanced_Logging').state === 'ON') {
        console.log(message);
    }
};

// Neue Hilfsfunktion für Radio-Wiederherstellung
const restoreRadio = async () => {
    await sendRequest(
        'PUT',
        `${OWNTONE_BASE_URL}/player/stop`,
        "application/json",
        ""
    );
    await new Promise(resolve => setTimeout(resolve, 500));

    // Aktive Radio-Pods ermitteln
    const currentActivePods = [];
    Object.entries(POD_MAP).forEach(([key, ids]) => {
        if (items.getItem(key).state === 'ON') {
            currentActivePods.push(...ids);
        }
    });

    if (currentActivePods.length > 0) {
        // Radio wiederherstellen
        await sendRequest(
            'PUT',
            `${OWNTONE_BASE_URL}/outputs/set`,
            "application/json",
            JSON.stringify({outputs: currentActivePods})
        );

        const numericVolume = parseInt(items.getItem('Radio_Volume').state, 10);
        for (const podId of currentActivePods) {
            await sendRequest(
                'PUT',
                `${OWNTONE_BASE_URL}/outputs/${podId}`,
                "application/json",
                JSON.stringify({volume: numericVolume})
            );
        }

        const radioUrl = getRadioUrl();
        await sendRequest(
            'PUT',
            `${OWNTONE_BASE_URL}/queue/clear`,
            "application/json",
            ""
        );
        
        await sendRequest(
            'POST',
            `${OWNTONE_BASE_URL}/queue/items/add?uris=${radioUrl}&clear=true`,
            "application/json",
            ""
        );

        await sendRequest(
            'PUT',
            `${OWNTONE_BASE_URL}/player/play`,
            "application/json",
            ""
        );
    }
};

// Radio Control Rule
rules.JSRule({
    name: "Radio Control",
    description: "Steuert die Radio-Funktionen der HomePods",
    triggers: [
        triggers.ItemStateUpdateTrigger('Radio_Bad'),
        triggers.ItemStateUpdateTrigger('Radio_Freisitz'),
        triggers.ItemStateUpdateTrigger('Radio_Kueche'),
        triggers.ItemStateUpdateTrigger('Radio_WZ'),
        triggers.ItemStateUpdateTrigger('Radio_SZ')
    ],
    execute: async (event) => {
        // Tür-/TTS-Priorität ist höher
        if (getPriority() > PRIORITIES.CONTROL) {
            console.log("Radio Control: Höherpriorisierte Aktion läuft, breche ab");
            return;
        }

        setPriority(PRIORITIES.CONTROL);

        try {
            if (!checkServerStatus()) {
                // Alle Radio-Items ausschalten
                ['Radio_Bad', 'Radio_Freisitz', 'Radio_Kueche', 'Radio_WZ', 'Radio_SZ'].forEach(item => {
                    items.getItem(item).sendCommand('OFF');
                });
                return;
            }

            if (!isRadioOperationInProgress()) {
                setOperationTimer(7);
                
                const activePods = [];
                Object.entries(POD_MAP).forEach(([key, ids]) => {
                    if (items.getItem(key).state === 'ON') {
                        activePods.push(...ids);
                    }
                });

                if (activePods.length > 0) {
                    // Aktivierung und Steuerung der HomePods
                    try {
                        const jsonActivate = JSON.stringify({outputs: activePods});
                        await sendRequest(
                            'PUT',
                            `${OWNTONE_BASE_URL}/outputs/set`,
                            "application/json",
                            jsonActivate
                        );

                        // Lautstärke setzen
                        const numericVolume = parseInt(items.getItem('Radio_Volume').state, 10);
                        for (const podId of activePods) {
                            await sendRequest(
                                'PUT',
                                `${OWNTONE_BASE_URL}/outputs/${podId}`,
                                "application/json",
                                JSON.stringify({volume: numericVolume})
                            );
                        }

                        // Radio starten
                        await sendRequest(
                            'PUT',
                            `${OWNTONE_BASE_URL}/queue/clear`,
                            "application/json",
                            ""
                        );

                        const radioUrl = getRadioUrl();
                        await sendRequest(
                            'POST',
                            `${OWNTONE_BASE_URL}/queue/items/add?uris=${radioUrl}&clear=true`,
                            "application/json",
                            ""
                        );

                        await sendRequest(
                            'PUT',
                            `${OWNTONE_BASE_URL}/player/play`,
                            "application/json",
                            ""
                        );

                        if (items.getItem('Advanced_Logging').state === 'ON') {
                            console.log(`HomePod Radio gestartet auf: ${activePods}`);
                        }
                    } catch (error) {
                        console.error(`HomePod Error: ${error}`);
                    }
                } else {
                    await sendRequest(
                        'PUT',
                        `${OWNTONE_BASE_URL}/player/stop`,
                        "application/json",
                        ""
                    );
                    if (items.getItem('Advanced_Logging').state === 'ON') {
                        console.log("Alle Radio-Items sind OFF, Player gestoppt.");
                    }
                }
            } else {
                console.log("Radio-Operation läuft bereits");
                radio_state_changed = true;
                setTimeout(() => {
                    if (radio_state_changed && !isRadioOperationInProgress()) {
                        radio_state_changed = false;
                        // Alle Radio-Items neu triggern
                        ['Radio_Bad', 'Radio_Freisitz', 'Radio_Kueche', 'Radio_WZ', 'Radio_SZ'].forEach(item => {
                            const currentState = items.getItem(item).state;
                            items.getItem(item).sendCommand(currentState);
                        });
                    }
                }, 6000);
            }
        } finally {
            resetPriority(PRIORITIES.CONTROL);
        }
    }
});

// Radio Sender Rule
rules.JSRule({
    name: "Radio Sender Control",
    description: "Verarbeitet Radiosender-Wechsel",
    triggers: [triggers.ItemStateUpdateTrigger('Aktueller_Radiosender')],
    execute: async () => {
        if (getPriority() > PRIORITIES.SENDER) {
            console.log("Radio Sender: Höherpriorisierte Aktion läuft, breche ab");
            return;
        }

        setPriority(PRIORITIES.SENDER);

        try {
            if (!checkServerStatus()) {
                if (items.getItem('Advanced_Logging').state === 'ON') {
                    console.log("Radiosender Wechsel nicht möglich - Server offline");
                }
                return;
            }

            if (!isRadioOperationInProgress()) {
                setOperationTimer(7);

                const activeRadios = ['Radio_Bad', 'Radio_Freisitz', 'Radio_Kueche', 'Radio_WZ', 'Radio_SZ']
                    .some(item => items.getItem(item).state === 'ON');

                if (activeRadios) {
                    try {
                        const radioUrl = getRadioUrl();
                        
                        // Queue leeren und neuen Sender starten
                        await sendRequest(
                            'PUT',
                            `${OWNTONE_BASE_URL}/queue/clear`,
                            "application/json",
                            ""
                        );
                        
                        await sendRequest(
                            'POST',
                            `${OWNTONE_BASE_URL}/queue/items/add?uris=${radioUrl}&clear=true`,
                            "application/json",
                            ""
                        );
                        
                        await sendRequest(
                            'PUT',
                            `${OWNTONE_BASE_URL}/player/play`,
                            "application/json",
                            ""
                        );

                        if (items.getItem('Advanced_Logging').state === 'ON') {
                            console.log(`Radiosender gewechselt zu: ${items.getItem('Aktueller_Radiosender').state}`);
                        }
                    } catch (error) {
                        console.error(`HomePod Sender Error: ${error}`);
                    }
                }
            }
        } finally {
            resetPriority(PRIORITIES.SENDER);
        }
    }
});

// TTS Rule
rules.JSRule({
    name: "TTS Control",
    description: "Verarbeitet TTS-Anfragen für HomePods",
    triggers: [triggers.ItemStateChangeTrigger('tts_datei_erstellung', 'OFF')],
    execute: async () => {
        // Nur ausführen, wenn nicht Doorbell aktiv
        if (getPriority() > PRIORITIES.TTS) {
            console.log("TTS: Höherpriorisierte Aktion läuft, breche ab");
            // TTS-Items trotzdem immer zurücksetzen
            ['Homepod_SZ_TTS', 'Homepod_Bad_TTS', 'Homepod_WZ_TTS', 
             'Homepod_Terrasse_Freisitz_TTS', 'Homepod_Kueche_TTS'].forEach(item => {
                items.getItem(item).sendCommand('OFF');
            });
            return;
        }

        setPriority(PRIORITIES.TTS);

        try {
            if (!checkServerStatus()) return;
            
            if (items.getItem('gHomePod_TTS').state === 'ON') {
                if (!isRadioOperationInProgress()) {
                    console.log("Keine Radio-Operation läuft");
                    setOperationTimer(30);

                    const TTS_POD_MAP = {
                        Homepod_SZ_TTS: ["222742452495664"],
                        Homepod_Bad_TTS: ["245003738814281"],
                        Homepod_WZ_TTS: ["143733425724839"],
                        Homepod_Terrasse_Freisitz_TTS: ["261851581089131", "117162080021876"],
                        Homepod_Kueche_TTS: ["156215867171413"]
                    };

                    const activePods = [];
                    Object.entries(TTS_POD_MAP).forEach(([key, ids]) => {
                        if (items.getItem(key).state === 'ON') {
                            activePods.push(...ids);
                        }
                    });

                    if (items.getItem('Advanced_Logging').state === 'ON') {
                        console.log(`Aktive Pods: ${activePods}`);
                    }

                    if (activePods.length > 0) {
                        try {
                            // Player stoppen
                            await sendRequest(
                                'PUT',
                                `${OWNTONE_BASE_URL}/player/stop`,
                                "application/json",
                                ""
                            );
                            await new Promise(resolve => setTimeout(resolve, 500));

                            // Aktivierung der HomePods
                            const jsonActivate = JSON.stringify({outputs: activePods});
                            await sendRequest(
                                'PUT',
                                `${OWNTONE_BASE_URL}/outputs/set`,
                                "application/json",
                                jsonActivate
                            );

                            // TTS Lautstärke setzen
                            for (const podId of activePods) {
                                const ttsVolume = getTTSVolume();
                                await sendRequest(
                                    'PUT',
                                    `${OWNTONE_BASE_URL}/outputs/${podId}`,
                                    "application/json",
                                    JSON.stringify({volume: ttsVolume})
                                );
                                advancedLog(`TTS Volume für Pod ${podId} auf ${ttsVolume} gesetzt`);
                            }

                            // TTS abspielen
                            await sendRequest(
                                'PUT',
                                `${OWNTONE_BASE_URL}/queue/clear`,
                                "application/json",
                                ""
                            );
                            
                            await sendRequest(
                                'POST',
                                `${OWNTONE_BASE_URL}/queue/items/add?uris=library:track:4&clear=true`,
                                "application/json",
                                ""
                            );

                            await sendRequest(
                                'PUT',
                                `${OWNTONE_BASE_URL}/player/play`,
                                "application/json",
                                ""
                            );

                            // Timer für Radio-Wiederherstellung
                            setTimeout(async () => {
                                const playerState = await getPlayerStatus();
                                advancedLog(`Player Status nach 9s: ${playerState}`);
                                
                                if (playerState === "stop") {
                                    // Direkt Radio wiederherstellen
                                    await restoreRadio();
                                    if (homepod_operation_timer) {
                                        clearTimeout(homepod_operation_timer);
                                        homepod_operation_timer = null;
                                        monitorOperation();
                                    }
                                } else if (playerState === "play") {
                                    // Weitere 12s warten
                                    setTimeout(async () => {
                                        await restoreRadio();
                                    }, 12000);
                                }
                            }, 9000);
                        } catch (error) {
                            console.error(`HomePod TTS Error: ${error}`);
                        }
                    }
                } else {
                    console.log(`Operation läuft: ${isRadioOperationInProgress()}`);
                }
                
                // TTS-Items zurücksetzen
                ['Homepod_SZ_TTS', 'Homepod_Bad_TTS', 'Homepod_WZ_TTS', 
                 'Homepod_Terrasse_Freisitz_TTS', 'Homepod_Kueche_TTS'].forEach(item => {
                    items.getItem(item).sendCommand('OFF');
                });
            }
        } finally {
            resetPriority(PRIORITIES.TTS);
        }
    }
});

// Doorbell Rule
rules.JSRule({
    name: "Doorbell Control",
    description: "Verarbeitet Türklingel-Events",
    triggers: [triggers.ItemStateUpdateTrigger('Doorbell')],
    execute: async () => {
        console.log(`Doorbell triggered, new state: ${items.getItem('Doorbell').state}`);
        if (items.getItem('Doorbell').state !== 'ON') {
            return;
        }
        // Höchste Priorität
        if (getPriority() > PRIORITIES.DOORBELL) {
            console.log("Doorbell: Höherpriorisierte Aktion läuft bereits");
            return;
        }

        setPriority(PRIORITIES.DOORBELL);

        try {
            if (!checkServerStatus()) return;
            
            if (!isRadioOperationInProgress()) {
                setOperationTimer(18);
                const allPods = ["245003738814281", "261851581089131", "117162080021876", 
                               "156215867171413", "222742452495664", "143733425724839"];

                if (doorbell_timer === null) {
                    doorbell_timer = setTimeout(() => {
                        doorbell_timer = null;
                    }, 30000);

                    try {
                        // Player stoppen
                        await sendRequest(
                            'PUT',
                            `${OWNTONE_BASE_URL}/player/stop`,
                            "application/json",
                            ""
                        );
                        await new Promise(resolve => setTimeout(resolve, 500));

                        // Alle HomePods aktivieren
                        const jsonActivate = JSON.stringify({outputs: allPods});
                        const activateResponse = await sendRequest(
                            'PUT',
                            `${OWNTONE_BASE_URL}/outputs/set`,
                            "application/json",
                            jsonActivate
                        );
                        console.log(`Doorbell: activateResponse=${activateResponse}`);

                        // Türklingel-Lautstärke setzen
                        for (const podId of allPods) {
                            await sendRequest(
                                'PUT',
                                `${OWNTONE_BASE_URL}/outputs/${podId}`,
                                "application/json",
                                JSON.stringify({volume: 60})
                            );
                        }

                        // Türklingel abspielen
                        await sendRequest(
                            'PUT',
                            `${OWNTONE_BASE_URL}/queue/clear`,
                            "application/json",
                            ""
                        );
                        await new Promise(resolve => setTimeout(resolve, 500));

                        const queueResponse = await sendRequest(
                            'POST',
                            `${OWNTONE_BASE_URL}/queue/items/add?uris=library:track:6&clear=true`,
                            "application/json"
                        );
                        console.log(`Doorbell: queueResponse=${queueResponse}`);

                        await sendRequest(
                            'PUT',
                            `${OWNTONE_BASE_URL}/player/play`,
                            "application/json",
                            ""
                        );

                        // Timer für Radio-Wiederherstellung
                        setTimeout(async () => {
                            await restoreRadio();
                        }, 9000);
                    } catch (error) {
                        console.error(`HomePod Doorbell Error: ${error}`);
                    }
                }
            } else {
                console.log("Operation läuft bereits, überspringe Türklingel");
            }
        } finally {
            resetPriority(PRIORITIES.DOORBELL);
        }
    }
});

// Radio Volume Rule
rules.JSRule({
    name: "Radio Volume Control",
    description: "Verarbeitet Lautstärke-Änderungen",
    triggers: [triggers.ItemStateUpdateTrigger('Radio_Volume')],
    execute: async () => {
        if (getPriority() > PRIORITIES.VOLUME) {
            console.log("Radio Volume: Höherpriorisierte Aktion läuft, breche ab");
            return;
        }

        setPriority(PRIORITIES.VOLUME);

        try {
            if (!checkServerStatus()) return;
            
            if (!isRadioOperationInProgress()) {
                setOperationTimer(7);
                try {
                    const volume = items.getItem('Radio_Volume').state;
                    await sendRequest(
                        'PUT',
                        `${OWNTONE_BASE_URL}/player/volume?volume=${volume}`,
                        "application/json",
                        ""
                    );
                } catch (error) {
                    console.error(`HomePod Volume Error: ${error}`);
                }
            } else {
                setTimeout(() => {
                    items.getItem('Radio_Volume').postUpdate(items.getItem('Radio_Volume').state);
                }, 10000);
            }
        } finally {
            resetPriority(PRIORITIES.VOLUME);
        }
    }
});

// Initialize Radio Volume
rules.JSRule({
    name: "Initialize Radio Volume",
    description: "Setzt initiale Radiolautstärke",
    triggers: [triggers.SystemStartlevelTrigger(100)],
    execute: () => {
        if (items.getItem('Radio_Volume').state === null) {
            items.getItem('Radio_Volume').postUpdate(30);
        }
    }
});