Restart-proof volume schedule: ramp a speaker toward a target volume (white noise, sleep sounds)

I use white noise at night and wanted it to rise slowly before dawn and come down again in the morning. My first version used repeat with a one minute delay and volume_set each step. It worked until Home Assistant restarted in the middle of the night: the running ramp was cancelled and none of the later ramps happened, because the automation that “was waiting” no longer existed.

This blueprint avoids that by not waiting at all. Every minute (and right after Home Assistant starts) it:

  1. Works out which time block is active and what volume that block wants.
  2. Reads the speaker’s live volume.
  3. Moves it one step (default 1%) toward the target.

Because there is no long-running delay, a restart, a reload or a speaker dropping off Wi-Fi does not break anything. On the next minute it continues from whatever volume the speaker has.

Features

  • Any number of time blocks, each with a start time and a target volume, stop, or none.
  • Optional media title filter (default noise), so it only touches the speaker when the right thing plays.
  • Hand-change detection: if someone changes the volume, it pauses until the next block starts.
  • Works with any media_player that supports volume_set (tested on Google Nest speakers via Cast).

Setup

  1. Create two helpers per speaker: a Number helper (min 0, max 1, step 0.01), and a Text helper.
  2. Import the blueprint, choose the speaker and the two helpers, edit the blocks.

Example blocks (the default):

- {start: "19:00", volume: 70}
- {start: "04:00", volume: 85}
- {start: "05:30", volume: 70}
- {start: "06:30", volume: 60}
- {start: "07:30", volume: stop}
- {start: "08:00", volume: none}

A block lasts until the next one starts, and the last block of the day carries over past midnight.

Notes

  • Starting the sound is up to you (a button, a voice routine, another automation). This blueprint only manages volume and the stop time.
  • Google speakers usually do not chime on volume changes while media is playing, which is why it only acts while playing.

Open your Home Assistant instance and show the blueprint import dialog with a specific blueprint pre-filled.

Source: Home Assistant blueprint: restart-proof volume schedule (ramp a speaker toward a target) · GitHub

Blueprint YAML
blueprint:
  name: Restart-proof volume schedule (ramp a speaker toward a target)
  description: >-
    Keeps a media player at a scheduled volume and moves toward each new target
    gradually (for example 1% per minute). Built for white noise or sleep sounds
    that should get louder at night and quieter in the morning.


    It is stateless: every minute (and right after Home Assistant starts) it reads
    the speaker's live volume and takes one step toward the target for the current
    time. Nothing waits in a long delay, so a restart or a speaker reconnecting never
    breaks a ramp, it simply continues from the current volume.


    If someone changes the volume by hand, the automation notices and leaves the
    speaker alone until the next time block starts.


    Needs two helpers you create once per speaker: a Number (0 to 1, step 0.01)
    that stores the last volume this automation set, and a Text helper that stores
    which block is paused.
  domain: automation
  input:
    speaker:
      name: Speaker
      selector:
        entity:
          domain: media_player
    blocks:
      name: Time blocks
      description: >-
        List of blocks, each with a start time (HH:MM) and a target volume in percent.
        Each block lasts until the next one starts. Use "stop" as the volume to stop
        playback during that block, or "none" to leave the speaker alone.
      default:
        - {start: "19:00", volume: 70}
        - {start: "04:00", volume: 85}
        - {start: "05:30", volume: 70}
        - {start: "06:30", volume: 60}
        - {start: "07:30", volume: stop}
        - {start: "08:00", volume: none}
      selector:
        object: {}
    title_filter:
      name: Only when the media title contains
      description: Leave empty to control anything that plays.
      default: noise
      selector:
        text: {}
    step:
      name: Step per minute (percent)
      default: 1
      selector:
        number: {min: 1, max: 10, unit_of_measurement: "%"}
    manual_tolerance:
      name: Manual change tolerance (percent)
      description: A difference larger than this from the last value set counts as a hand change.
      default: 3
      selector:
        number: {min: 1, max: 20, unit_of_measurement: "%"}
    last_set_helper:
      name: Helper, last volume set (Number 0 to 1, step 0.01)
      selector:
        entity:
          domain: input_number
    hold_helper:
      name: Helper, paused block (Text)
      selector:
        entity:
          domain: input_text

mode: single
max_exceeded: silent

triggers:
  - trigger: time_pattern
    minutes: "/1"
  - trigger: homeassistant
    event: start

variables:
  speaker: !input speaker
  blocks: !input blocks
  title_filter: !input title_filter
  step: !input step
  tolerance: !input manual_tolerance
  last_e: !input last_set_helper
  hold_e: !input hold_helper
  hm: "{{ now().strftime('%H:%M') }}"
  # The active block is the one with the latest start time not after now; before the
  # earliest start, the block that started last (yesterday) is still active.
  sorted_blocks: "{{ blocks | sort(attribute='start') }}"
  current: >-
    {% set past = sorted_blocks | selectattr('start', 'le', hm) | list %}
    {{ (past | last) if past else (sorted_blocks | last) }}
  block: "{{ current.start }}"
  target: "{{ current.volume | string | lower }}"
  vol: "{{ state_attr(speaker, 'volume_level') | float(-1) }}"
  last: "{{ states(last_e) | float(-1) }}"
  fresh: >-
    {{ states[speaker] is not none
       and (now() - states[speaker].last_changed).total_seconds() < 120 }}

conditions:
  - condition: template
    value_template: >-
      {{ is_state(speaker, 'playing') and vol >= 0
         and title_filter | lower in (state_attr(speaker, 'media_title') or '') | lower
         and target != 'none' }}

actions:
  - if: "{{ target == 'stop' }}"
    then:
      - action: media_player.media_stop
        target: {entity_id: "{{ speaker }}"}
      - stop: Scheduled stop
  # A new block lifts any pause from a hand change.
  - if: "{{ states(hold_e) not in ['', block] }}"
    then:
      - action: input_text.set_value
        target: {entity_id: "{{ hold_e }}"}
        data: {value: ""}
  # Playback just started or the speaker just reconnected: adopt its current volume.
  - if: "{{ fresh }}"
    then:
      - action: input_number.set_value
        target: {entity_id: "{{ last_e }}"}
        data: {value: "{{ vol | round(2) }}"}
      - stop: Adopted the current volume
  - condition: template
    value_template: "{{ states(hold_e) != block }}"
  - if: "{{ (vol - last) | abs > tolerance / 100 }}"
    then:
      - action: input_text.set_value
        target: {entity_id: "{{ hold_e }}"}
        data: {value: "{{ block }}"}
      - stop: Volume changed by hand, paused until the next block
  - variables:
      t: "{{ (target | float(0)) / 100 }}"
      s: "{{ step / 100 }}"
      new: >-
        {{ ([vol + s, t] | min) if vol < t - 0.005
           else (([vol - s, t] | max) if vol > t + 0.005 else vol) }}
  - condition: template
    value_template: "{{ (new - vol) | abs > 0.004 }}"
  - action: media_player.volume_set
    target: {entity_id: "{{ speaker }}"}
    data: {volume_level: "{{ new | round(2) }}"}
  - action: input_number.set_value
    target: {entity_id: "{{ last_e }}"}
    data: {value: "{{ new | round(2) }}"}

Feedback welcome, especially from people using other speakers.