Audio reminders in openHAB

Posted by ads' corner on Thursday, 2020-10-15
Posted in [Ansible][Openhab]

A while ago someone mentioned “reminders” used in their home audio system, and I took that idea and implemented something similar in openHAB.

The basic idea is that I can send scheduled notifications to any audio sink openHAB is using, possibly more than one audio sink for one message. Also I want to differentiate between a simple audio sound, and text output.

For the text audio output I installed Text-to-Speech a while ago, this enables the ability to output text as audio in different languages. In addition I want an information when a reminder is “fired” in my Telegram control channel. As audio sink I’m mostly using ChromeCasts here, but anything openHAB can connect to is usable here.

The implementation is split into two parts:

  • A YAML configuration file, which is included in Ansible
  • A rule file which is deployed as template using Ansible

The openHAB DSL is kind of limited for what I want to do with it, therefore I’m using Ansible to deploy everything, and along the way expand loops and such into functional code. Using Ansible has another upside for me: the entire openHAB setup is created using Ansible, at any point I can just reinstall everything, and don’t need to rely on backups. Did that once shortly after I started using openHAB, when the SDcard was dead because of the many writes (see here), and reinstalled everything a second time when I moved from a Rasperry 3 to a 4. That said, I have backups ;-) but I don’t really need it.

The configuration file

This file creates a single variable for Ansible, holding a list of dictionaries.

1
2
3
4
5
reminders:
  - { name: '<unique name>', enabled: 1, time: '<Timer schedule>', action: 'say', volume: '50',
      message: '<text for TTS>', language: 'picotts:enUS', sink: [ "Kitchen Audio", "Kids Room Audio" ] }
  - { name: 'Get ready for school', enabled: 0, time: '0 15 7 ? * MON,TUE,WED,THU,FRI *', action: 'say', volume: '50',
      message: 'Get ready for school', language: 'picotts:enUS', sink: [ "Kitchen Audio", "Kids Room Audio" ] }

The above two entries show an example, the following fields are used in the dictionary:

  • name: a unique name, can be anything, will only be used for status messages (Telegram and such)
  • enabled: 0/1, if not enabled the reminder will only emit a notice in the logfile (for debugging)
  • time: a cron schedule (use this or this website to create a schedule)
  • action: either say (for Text-to-Speech) or sound for playing an audio file
  • volume: the volume which the audio sink should be using
  • message: only used when action=say, the text for the TTS system
  • audio: only used when action=sound, the audio file to be played, must exist on the openHAB system in the audio folder
  • language: the TTS language
  • sink: list with audio sinks (I have the ChromeCasts in a YAML definition as well, and can address them by name and don’t need to use the ID)

The rules source file

By separating configuration and code I don’t need to touch the actual rules file when changing the schedule. I however have to re-deploy the configuration when the schedule changes. Maybe a future version can improve this.

First I need to include the configuration file in Ansible:

1
2
3
4
5
  tasks:
    - include_vars:
        file: "{{ playbook_dir }}/openhab/chromecast.yml"
    - include_vars:
        file: "{{ playbook_dir }}/openhab/reminders.yml"

For reference, here is the YAML configuration for the “Kitchen Audio” ChromeCast:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
CC_Kitchen_Audio_ID: "3105de263085d3df8f9021728582a2da"
CC_Kitchen_Audio: "chromecast:audio:{{ CC_Kitchen_Audio_ID }}"
CC_Kitchen_Audio_Text: "{{ CC_Kitchen_Audio | replace(':','_') }}"
CC_Kitchen_Audio_Name: "Kitchen Audio"
CC_Kitchen_Audio_IP: "192.168.5.42"
CC_Kitchen_Audio_Type: "audio"
CC_Kitchen_Audio_Port: "8009"

chromecasts:
  - { name: "{{ CC_Kitchen_Audio_Name }}", id: "{{ CC_Kitchen_Audio_ID }}", fullid: "{{ CC_Kitchen_Audio }}", textid: "{{ CC_Kitchen_Audio_Text }}",
      type: "{{ CC_Kitchen_Audio_Type }}", port: "{{ CC_Kitchen_Audio_Port }}", ip: "{{ CC_Kitchen_Audio_IP }}" }

The loops in Ansible templates require the use of namespaces.

And now the actual rule:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
{% for r in reminders %}
rule "Reminder: {{ r['name'] }}"
when
    Time cron "{{ r['time'] }}"
then
    {% if r['enabled'] == 1 %}

        logInfo("Reminder", "{{ r['name'] }}")
        val telegramAction = getActions("telegram","telegram:telegramBot:HA_Bot")
        telegramAction.sendTelegram(Long::parseLong(TELEGRAM_CHANNEL_HA_DETAILS), "Reminder: %s", "{{ r['name'] }}")
        {% for as in r['sink'] %}

        // loop over all audio sinks (ChromeCasts) and find the one specified by name
        {% set ns.as_found = 0 %}
        {% set ns.as_used = '' %}
        {% set ns.as_used_us = '' %}
        {% for cc in chromecasts %}
        {% if cc['name'] == as %}
        {% set ns.as_found = 1 %}
        {% set ns.as_used = cc['fullid'] %}
        {% set ns.as_used_us = cc['fullid']|replace(':','_') %}
        {% endif %}

        {% endfor %}

        logInfo("Reminder Debug", "found: {{ ns.as_found }}")
        {{ ('' if ns.as_found == 1) | mandatory('ChromeCast (' + as + ') not found!') }}
        logInfo("Reminder", "Play on: {{ ns.as_used }}")
        if ({{ ns.as_used_us }}_volume.state != {{ r['volume'] }}) {
            {{ ns.as_used_us }}_volume.sendCommand({{ r['volume'] }})
        }
        {% if r['action'] == "say" %}

        playSound("{{ ns.as_used }}", "1-second-of-silence.mp3")
        Thread::sleep(1100)
        playSound("{{ ns.as_used }}", "computerbeep_69.mp3")
        Thread::sleep(800)
        say("{{ r['message'] }}", "{{ r['language'] }}", "{{ ns.as_used }}")

        {% else %}

        playSound("{{ ns.as_used }}", "1-second-of-silence.mp3")
        Thread::sleep(1100)
        playSound("{{ ns.as_used }}", "{{ r['audio'] }}")

        {% endif %}

        {% endfor %}
    {% else %}

        // post a note that the reminder "fired", but is disabled
        logInfo("Reminder", "{{ r['name'] }} (disabled)")
    {% endif %}

end

{% endfor %}

And in detail:

Loop over all events, use only the ones which are enabled. Output a log note if an evert is disabled:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{% for r in reminders %}
rule "Reminder: {{ r['name'] }}"
when
    Time cron "{{ r['time'] }}"
then
    {% if r['enabled'] == 1 %}

        logInfo("Reminder", "{{ r['name'] }}")
    {% else %}

        // post a note that the reminder "fired", but is disabled
        logInfo("Reminder", "{{ r['name'] }} (disabled)")
    {% endif %}
end
{% endfor %}

Send a Telegram message:

1
2
        val telegramAction = getActions("telegram","telegram:telegramBot:HA_Bot")
        telegramAction.sendTelegram(Long::parseLong(TELEGRAM_CHANNEL_HA_DETAILS), "Reminder: %s", "{{ r['name'] }}")

Loop over the list of audio sinks, and identify each. This will repeat the actual output code for each audio sink:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
        {% for as in r['sink'] %}

        // loop over all audio sinks (ChromeCasts) and find the one specified by name
        {% set ns.as_found = 0 %}
        {% set ns.as_used = '' %}
        {% set ns.as_used_us = '' %}
        {% for cc in chromecasts %}
        {% if cc['name'] == as %}
        {% set ns.as_found = 1 %}
        {% set ns.as_used = cc['fullid'] %}
        {% set ns.as_used_us = cc['fullid']|replace(':','_') %}
        {% endif %}

        {% endfor %}

        logInfo("Reminder Debug", "found: {{ ns.as_found }}")
        {{ ('' if ns.as_found == 1) | mandatory('ChromeCast (' + as + ') not found!') }}
        logInfo("Reminder", "Play on: {{ ns.as_used }}")
        {% endfor %}

The part for identifying the audio sink is a bit more complicated, because I want to use the plain name in the configuration, and need to loop over the list of ChromeCasts ($chromecasts) to find the actual entry. And bail out if a non-existent audio sink is specified. The error is already raised when the template is deployed by Ansible, which helps identifying any mistakes early on.

Next is setting the volume, but only if the volume setting is different from what is specified:

1
2
3
        if ({{ ns.as_used_us }}_volume.state != {{ r['volume'] }}) {
            {{ ns.as_used_us }}_volume.sendCommand({{ r['volume'] }})
        }

Then decide if TTS is used, or an audio file is played:

1
2
3
4
5
        {% if r['action'] == "say" %}

        {% else %}

        {% endif %}

The TTS part:

1
2
3
4
5
        playSound("{{ ns.as_used }}", "1-second-of-silence.mp3")
        Thread::sleep(1100)
        playSound("{{ ns.as_used }}", "computerbeep_69.mp3")
        Thread::sleep(800)
        say("{{ r['message'] }}", "{{ r['language'] }}", "{{ ns.as_used }}")

The ChromeCasts notoriously absorb the first bit of audio played on them, therefore the first thing played is a 1 second audio file with just silence as content. This will activate the ChromeCast, and smooth the next steps. The computerbeep_69.mp3 is just a nice touch, announcing that next comes amessage. If you ever watched Star Trek you know what I mean. And finally, the say() command outputs the actual message on the audio sink.

The Sound part:

1
2
3
        playSound("{{ ns.as_used }}", "1-second-of-silence.mp3")
        Thread::sleep(1100)
        playSound("{{ ns.as_used }}", "{{ r['audio'] }}")

Play the 1 second silence file to activate the ChromeCast, then play the specified audio file.

The rules file deployed

As mentioned before, the template above is expanded by Ansible during deployment, variables are filled in, loops are unrolled, ect.

The final file which ends up on the openHAB server looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
rule "Reminder: Get ready for school"
when
    Time cron "0 0 7 ? * MON,TUE,WED,THU,FRI *"
then

        logInfo("Reminder", "Get ready for school")
        val telegramAction = getActions("telegram","telegram:telegramBot:HA_Bot")
        telegramAction.sendTelegram(Long::parseLong(TELEGRAM_CHANNEL_HA_DETAILS), "Reminder: %s", "Get ready for school")

        // loop over all audio sinks (ChromeCasts) and find the one specified by name
        logInfo("Reminder Debug", "found: 1")

        logInfo("Reminder", "Play on: chromecast:audio:3105de263085d3df8f9021728582a2da")
        if (chromecast_audio_3105de263085d3df8f9021728582a2da_volume.state != 50) {
            chromecast_audio_3105de263085d3df8f9021728582a2da_volume.sendCommand(50)
        }

        playSound("chromecast:audio:3105de263085d3df8f9021728582a2da", "1-second-of-silence.mp3")
        Thread::sleep(1100)
        playSound("chromecast:audio:3105de263085d3df8f9021728582a2da", "computerbeep_69.mp3")
        Thread::sleep(800)
        say("Get ready for school", "picotts:enUS", "chromecast:audio:3105de263085d3df8f9021728582a2da")

end

Categories: [Ansible] [Openhab]