Script for Sonos Speakers to Announce Upcoming Alarm

This blueprint is used to add a script that will announce when the next alarm is set on the specified Sonos speaker. If the alarm is less than two hours away it will indicate how long until it rings. Otherwise the alarm will indicate the time when the alarm is set using a 12 hour format (e.g. 1 AM vs 1 PM). If it cannot find an alarm it will indicate that as well.

It will gracefully handle when speakers are in groups, and restore playing music after it announces the alarm.

The following field parameters can be given when the script is called:

  • [required] Sonos speaker to check for an alarm
  • [optional] Sonos speaker to announce the time of alarm
  • [optional] Time window to look for an alarm to announce. Maximum time is 1439 minutes (23 hours and 59 minutes), minimum is 30 minutes and the default is 720 minutes (12 hours)
  • [optional] Volume to used to play the announcement.
  • [optional] Minimum wait to ensure state changes in Sonos are correct (advanced setting).
  • [optional] Maximum wait to help ensure messages finish playing (advanced setting).

This script is particularly convenient when:

  • You don’t want to check phone apps when going to bed to see if/when an alarm is set
  • You have have a phyiscal button near your bed (e.g. a Lutron Caseta Pico remote)

How to Install

This automation blueprint relies on another script blueprint I previously created. Since Home Assistant blueprints don’t support dependencies, the dependency has to be installed manually. I recommend the following installation order:

  1. Install the Sonos Text-to-Speech script blueprint (details here) and create a script with an Entity ID of sonos_say and optionally change the text-to-speech provider and language
    Open your Home Assistant instance and show the blueprint import dialog with a specific blueprint pre-filled.
  2. Install this Announce Sonos Alarm automation blueprint
    Open your Home Assistant instance and show the blueprint import dialog with a specific blueprint pre-filled.

Additional Notes

  • I recommend setting the mode to parallel when using the same script for multiple automations
     
# ***************************************************************************
# *  Copyright 2022 Joseph Molnar
# *
# *  Licensed under the Apache License, Version 2.0 (the "License");
# *  you may not use this file except in compliance with the License.
# *  You may obtain a copy of the License at
# *
# *      http://www.apache.org/licenses/LICENSE-2.0
# *
# *  Unless required by applicable law or agreed to in writing, software
# *  distributed under the License is distributed on an "AS IS" BASIS,
# *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# *  See the License for the specific language governing permissions and
# *  limitations under the License.
# ***************************************************************************

# Announces when the next alarm is set on a Sonos speaker.
# Future considerations
# - blueprint-based 12 vs 24 clock

blueprint:
  name: "Announce Sonos Alarm"
  description:
    This blueprint is used to add a script that will announce when the next alarm is set on the
    specified Sonos speaker. If the alarm is less than two hours away it will indicate how long
    until it rings. Otherwise the alarm will indicate the time when the alarm is set using a
    12 hour format (e.g. 1 AM vs 1 PM). If it cannot find an alarm it will indicate that as well.

    The script will gracefully handle when speakers are in groups, and restore playing music
    after it announces the alarm.

    This is helpful as part of an automation when going to bed so you don't need to use a phone
    app to see when/if an alarm is set.

    I recommend setting the mode to parallel if you will use this script on more than one speaker.
  domain: script

fields:
  entity_id:
    description:
      The entity id of the Sonos speaker to find the alarm and, if `announce_entity_id`
      isn't set, then the speaker to announce on.
    name: Alarm Entity
    required: true
    selector:
      entity:
        domain: media_player
        integration: sonos
  announce_entity_id:
    description:
      An optional entity id for a speaker, which doesn't have to be from Sonos,
      to announce the alarm found on `entity_id`. If this isn't specified then
      the alarm is announced on `entity_id`.
    name: Announce Entity
    required: false
    selector:
      entity:
        domain: media_player
  time_window:
    name: Time Window
    description:
      The amount of time, in minutes, to look forward for an alarm. Maximum time is 1439 minutes
      (23 hours and 59 minutes), minimum is 30 minutes and the default is 720 minutes (12 hours).
    required: false
    default: 720
    selector:
      number:
        min: 30
        max: 1439
        unit_of_measurement: minutes
        mode: slider
  volume_level:
    name: "Volume Level"
    description: "Float for volume level. Range 0..1. If value isn't given, volume isn't changed."
    required: false
    selector:
      number:
        min: 0
        max: 1
        step: 0.01
        mode: slider
  min_wait:
    name: "Minimum Wait"
    description:
      The minimum number fo seconds that the system will wait for state changes from
      Sonos. See the sonos_say script for details.
    required: false
    selector:
      number:
        min: 0
        max: 60
        step: 0.25
        unit_of_measurement: seconds
        mode: slider

  max_wait:
    name: "Maximum Wait"
    description:
      After the system starts playing the message the system waits for the message
      to finish play. See the sonos_say script for details.
    required: false
    selector:
      number:
        min: 1
        max: 60
        step: 0.25
        unit_of_measurement: seconds
        mode: slider

variables:
  valid_time_window: >-
    {# make sure we have a valid time window to use #}
    {{ 720 if ( time_window is undefined or time_window < 30 ) else 1439 if time_window > 1439 else time_window }}
  alarm: >-
    {# Note: I'm new to jinja so there may be more efficient ways to do this...if so, contact me #}
    {%- set entities = device_entities( device_id( entity_id ) ) -%}
    {# since the current day can flip while running this script and we want this as accurate as #}
    {# possible, we capture the current time right away and base all calculations on it so there #}
    {# are no oddities but does mean up front math and more variables to use later #}
    {%- set current_time = now() -%}
    {# set for current midnight to help with calculating alarms for today below #}
    {# again, doesn't use today_at() to ensure no cross-over in the day during execution #}
    {%- set current_midnight = current_time.replace(hour=0,minute=0,second=0,microsecond=0) %} 
    {# we track tomorrow to help with cross-overs at midnight #}
    {%- set tomorrow_midnight = current_midnight + timedelta(days=1) -%} 
    {# the Sonos alarms start the week on Sunday but start at 0 and the ISO week starts on Monday #}
    {# but starts at 1, so to correct the math we just modulo against 7 #}
    {%- set current_weekday = current_midnight.isoweekday() % 7 -%}
    {%- set tomorrow_weekday = ( current_weekday + 1 ) % 7 -%}
    {# we use this namespace to track the alarm time info we find #}
    {%- set alarm_timing = namespace( days = "", time={}) -%}
    {# we set initial delta to help processing later #}    
    {%- set found_alarm = namespace( found = false, entity_id = "", time = "", delta = timedelta(minutes=valid_time_window )) -%} 
    {# we iterate through all the entities on the specified entity looking for alarms #}
    {%- for entity_id in entities -%}
      {%- set entity_id_parts = entity_id.split('.') -%}
      {%- set entity = states[entity_id_parts[0]][entity_id_parts[1]] -%}
      {%- set alarm_id = state_attr( entity_id, "alarm_id") -%}
      {# we see if we found a switch that has an alarm #}
      {%- if entity.domain == "switch" and alarm_id != None and entity.state == "on" -%}
        {# we need to figure out which days the alarm is on #}
        {%- set recurrence = state_attr( entity_id, "recurrence") -%}
        {%- if recurrence == "DAILY" -%}
          {# if a daily alarm, we just indicate we will look across all the days #}
          {%- set alarm_timing.days = "0123456" -%}
        {%- elif recurrence == "WEEKDAYS" -%}
          {# if weekdays we just indicate we will look across Monday through Friday #}
          {%- set alarm_timing.days = "12345" -%}
        {%- elif recurrence == "WEEKENDS" -%}
          {# if a daily alarm, we just indicate we will look across Saturday and Sunday #}
          {%- set alarm_timing.days = "06" -%}
        {%- else -%}
          {# if not a daily alarm, the format is ON_#### (e.g. ON_1236), where ### are numbers of the weekdays #}
          {%- set alarm_timing.days = recurrence.split('_')[1] -%}
        {%- endif -%}
        {# we need the time as a delta so we can add later, yes this is hefty work #}
        {# but I don't know another way to take a string and turn into a timedelta #}
        {%- set alarm_time = state_attr( entity_id, "time") -%}
        {%- set alarm_time_parts = alarm_time.split(':') -%}
        {%- set alarm_timing.time = timedelta( hours=alarm_time_parts[0] | int, minutes=alarm_time_parts[1] | int ) -%}
        {# so now we loop through each day to see if we have a matching alarm #}
        {# ideally we'd shortcircuit once we find it or go too far #}
        {%- for day_string in alarm_timing.days %}
          {%- set day = day_string | int -%}
          {%- if day == current_weekday -%}
            {# if the alarm is for today then we use current_midnight as a basis for time comparison #}
            {%- set alarm_timestamp = current_midnight + alarm_timing.time-%}
            {%- set delta = alarm_timestamp - current_time -%}
            {# delta can't be negative, and replace alarm if delta is less than the last found (which includes never found) #}
            {%- if delta >= timedelta() and found_alarm.delta >= delta -%}
              {%- set found_alarm.found = true -%}
              {%- set found_alarm.entity_id = entity_id %}
              {%- set found_alarm.time = alarm_time %}
              {%- set found_alarm.call_timestamp = current_time %}
              {%- set found_alarm.delta = delta %}
            {%- endif -%}
          {%- elif day == tomorrow_weekday %}
            {# if the alarm is for tomorrow then we use tomorrow_midnight as a basis for time comparison #}
            {%- set alarm_timestamp = tomorrow_midnight + alarm_timing.time-%}
            {%- set delta = alarm_timestamp - current_time -%}
            {# delta can't be negative, and replace alarm if delta is less than the last found (which includes never found) #}
            {%- if delta >= timedelta() and found_alarm.delta >= delta -%}
              {%- set found_alarm.found = true -%}
              {%- set found_alarm.entity_id = entity_id %}
              {%- set found_alarm.time = alarm_time %}
              {%- set found_alarm.call_timestamp = current_time %}
              {%- set found_alarm.delta = delta %}
            {%- endif -%}
          {%- endif -%}
        {%- endfor %}
      {%- endif -%}
    {%- endfor -%}
    {# if we find the alarm then we can stop / pause it #}
    {%- if found_alarm.found == true %}
      {# return back the delta to help calculate when to re-enable the alarm, yes missing microseconds, but close enough #}
      {% set delta_hours = ( found_alarm.delta.seconds / ( 3600 ) ) | int -%}
      {% set remaining_seconds = found_alarm.delta.seconds - ( delta_hours * 3600 ) | int -%}
      {% set delta_minutes = ( remaining_seconds / 60 ) | int  %}
      {% set remaining_seconds = remaining_seconds - ( delta_minutes * 60 ) -%}
      {{ { "found": true, "entity_id": found_alarm.entity_id, "time": found_alarm.time, "call_timestamp" : found_alarm.call_timestamp | string, "delta": { "hours": delta_hours,"minutes": delta_minutes, "seconds" : remaining_seconds } } }}
    {%- else -%}
      {{ { "found": false } }}
    {%- endif -%}
  actual_announce_entity_id: >-
    {# for playback we see if we have an announce_entity_id and use it #}
    {# otherwise we use the entity_id #}
    {%- if announce_entity_id is undefined or announce_entity_id == None or announce_entity_id == "" -%}
      {# so we don't have an announce_entity_id so now we see if we #}
      {# are in a group since the repeat is typically controlled by it #}
      {# we use this for doing all the work since it is the primary speaker #}
      {# and everything will be shared across speakers anyhow #}
      {%- set group_members = state_attr( entity_id, "group_members" ) -%}
      {%- if group_members == None -%}
        {# we maybe on an older version of HA, so look for a different group name#}
        {%- set group_members = state_attr( entity_id, "sonos_group" ) -%}
        {%- if group_members == None -%}
          {{ entity_id }}
        {%- else -%}
          {{ group_members[0] }}
        {%- endif -%}
      {%- else -%}
        {# the first seems to be the control, at least on Sonos #}
        {{ group_members[0] }}
      {%- endif -%}
    {%- else -%}
      {# we have the announce_entity_id, so we use it #}
      {{ announce_entity_id }}
    {%- endif -%}

  message: >-
    {%- if alarm.found == false -%}
      {# an alarm wasn't found so we indicate that #}

      {# calculate strings to indicate the hours / minutes we checked #} 
      {# but words change depending IF / HOW MANY hours / minutes we have#}
      {% set window_hours = ( valid_time_window / ( 60 ) ) | int -%}
      {% set window_minutes = valid_time_window - ( window_hours * 60 ) -%}
      {# so first the hours #}
      {%- set hour_string = "" -%}
      {%- if window_hours == 1 -%}
        {%- set hour_string = "1 hour" -%}
      {%- elif window_hours > 1 -%}
        {%- set hour_string = ( window_hours | string) + " hours" -%}
      {%- endif -%}
      {# then the minutes #}
      {%- set minute_string = "" -%}
      {%- if window_minutes == 1 -%}
        {%- set minute_string = "1 minute" -%}
      {%- elif window_minutes > 1 -%}
        {%- set minute_string = ( window_minutes | string) + " minutes" -%}
      {%- endif -%}
      {# then combine the hours and minutes #}
      {%- set delta_string = hour_string + ( " and " if ( hour_string != "" and minute_string != "" ) else "" ) + minute_string  -%}
      {%- if delta_string == "" -%}
        {# this should never happen, but will put something just in case #}
        "Couldn't find an alarm"
      {%- else -%}
        "Couldn't find an alarm over the next {{ delta_string }}"
      {%- endif -%}

    {%- else -%}
      {# an alarm was found so we indicate that #}

      {%- set window_timedelta = timedelta( hours = 2 ) -%}
      {%- set alarm_timedelta = timedelta(hours=alarm.delta.hours, minutes=alarm.delta.minutes, seconds=alarm.delta.seconds) -%}
      {%- if alarm_timedelta <= window_timedelta -%}
        {# we are less than the two hour delta #}
        {# calculate strings to indicate the hours / minutes until ringing #} 
        {# but words change depending IF / HOW MANY hours / minutes we have#}
        {# so first the hours #}
        {%- set hour_string = "" -%}
        {%- if alarm.delta.hours == 1 -%}
          {%- set hour_string = "1 hour" -%}
        {%- elif alarm.delta.hours > 1 -%}
          {%- set hour_string = ( alarm.delta.hours | string) + " hours" -%}
        {%- endif -%}
        {# then the minutes #}
        {%- set minute_string = "" -%}
        {%- if alarm.delta.minutes == 1 -%}
          {%- set minute_string = "1 minute" -%}
        {%- elif alarm.delta.minutes > 1 -%}
          {%- set minute_string = ( alarm.delta.minutes | string) + " minutes" -%}
        {%- endif -%}
        {# then combine the hours and minutes #}
        {%- set delta_string = hour_string + ( " and " if ( hour_string != "" and minute_string != "" ) else "" ) + minute_string  -%}
        {%- if delta_string == "" -%}
          {# if no hour or minute, it means it is about to go off #}
          "Alarm rings in less than a minute" 
        {%- else -%}
          "Alarm rings in {{ delta_string }}"
        {%- endif -%}

      {%- else -%}
        {# the time format on Sonos alarms is HH:MM:SS in 24-hour format #}
        {# so we extract the hours and minutes and make it 12-hour based #}
        {# and add the AM or PM #}
        {%- set alarm_time_parts = alarm.time.split(':') -%}
        {%- set alarm_time_hour = alarm_time_parts[0] | int -%}
        {%- set alarm_time = ( alarm_time_parts[0] if alarm_time_hour < 13 else ( alarm_time_hour - 12 ) | string ) + ":" + alarm_time_parts[1] + ( " AM" if alarm_time_hour < 13 else " PM" ) -%}
        
        {% if today_at(alarm.time) < strptime(alarm.call_timestamp, "%Y-%m-%d %H:%M:%S.%f%z") %}
          {# this means the alarm goes off tomorrow sometime, so add the tomorrow #}
          "Alarm set for tomorrow at {{ alarm_time }}"
        {%- else -%}            
          {# this means the alarm goes off today #}
          "Alarm set for {{ alarm_time }}"
        {%- endif -%}
      {%- endif -%}
    {%- endif -%}

sequence:
  - service: script.sonos_say
    data:
      entity_id: "{{ actual_announce_entity_id }}"
      message: "{{ message }}"
      volume_level: "{{ volume_level|default(None) }}"
      min_wait: "{{ min_wait|default(None) }}"
      max_wait: "{{ max_wait|default(None) }}"

mode: parallel
max_exceeded: silent
icon: mdi:account-voice

Revisions

  • 2022-10-28: Added optional playback speaker and added better handling of optional parameters for volume and waits
  • 2022-10-16: Simplified implementation and now depends on the Sonos Text-to-Speech script script allowing it take advantage of improvements
  • 2022-05-13: Added setting volume and fixed bug when Sonos reports recurrence as WEEKDAYS and WEEKENDS
  • 2022-05-07: Initial release

Available Blueprints

1 Like

Continuing my series of music and Sonos related scripts. This one is similar to my alarm stopping script (see here). It also tries to be a bit more ‘human’ in how it announces the alarm by being aware of how far away an alarm is and if the alarm is today or tomorrow.

Again, lots of comments in the code to explain what I did. If you notice any issues, please let me know!

Just updated the script to fix a bug and allow the volume of the announcement to be set. Regarding the bug, when the Sonos alarm is either setup for only weekdays or only weekends it reports a different style of recurrence value than observed for other day configurations.

Just updated this blueprint to use my Sonos Text-to-Speech script, which simplified the code dramatically and brought some improvements to volume and voice handling. Having this dependency means all improvements in that script will reflect here and makes both scripts easier to maintain but does make initial installation more of a pain.

I updated the blueprint again, including better handling of optional parameters, but also a new feature that allows you to optionally specify which speaker to announce the alarm on.

To do this you use the entity_id to indicate which Sonos speaker you want to find the alarm on and the announce_entity_id to indicate which speaker you want to announce on. The announce_entity_id does not need to be from Sonos, it can be any media_player.

If you don’t specify announce_entity_id then it works like before and plays back through entity_id.