Remember volume levels of each source for a media player, to automate restoring

Hello!

I have spent the morning trying to find a way to monitor the volume_level+source of a media player (a universal media player, but I think that is immaterial), so that I might set the volume_level to the last used level for that source.

My AVR (Denon) for whatever reason plays HEOS/Spotify/streams at SUCH a loud volume compared to anything else. I have to have it at ~40% volume, but everything else at 65%.

I had this idea to use an input_text to store a JSON blob mapping source name to last-known volume level and combine it with an automation which triggered on the source of the media player changing.

Unfortunately, I’m having a hell of a time trying to construct JSON in a template. I suspect I could use Node-RED for this but I don’t yet have any experience with it and really would prefer to keep the definition for this automation in my media_centre.yaml package if possible.

What I’ve tried:

{% set volumes = iif(is_state('input_text.cached_volumes', 'unknown'), "{}", states('input_text.cached_volumes')) | from_json %}
{% set source = state_attr('media_player.living_room', 'source') %}
{% set level = state_attr('media_player.living_room', 'volume_level') %}

{# This raises a SecurityError #}
{% set _ = volumes.update({source: level}) %}

{# This allows me to _set_ keys but I can't turn it into JSON and I can't use dynamic key names #}
{% set volumes = namespace(volumes) %}
{% set volumes.foo = level %}

The closest I can get (which tbh may be workable) is to store a list of dictionaries as json, instead of single dictionary, but then the update logic will be quite more difficult to avoid duplicating entries:

{% set volumes = iif(is_state('input_text.cached_volumes', 'unknown'), "[]", states('input_text.cached_volumes')) | from_json %}
{% set source = state_attr('media_player.living_room', 'source') %}
{% set level = state_attr('media_player.living_room', 'volume_level') %}
{% set volumes = volumes + [{"source": "e.g. source", "volume_level": 0.3}] %}
{% set volumes = volumes + [{"source": source, "volume_level": level}] %}
{{ volumes | to_json }}

which outputs:

[{"source": "e.g. source", "volume_level": 0.3}, 
 {"source": "HEOS Music", "volume_level": 0.5}]

Is this my best bet? Am I missing an obvious way to do this more easily (besides Node-RED, which I will definitely look into if I am not left with a superior option).

Yeah so that works well enough (a dict would be preferable to avoid repeated keys), but of course I forgot that the input_text's state is limited to 255 chars anyway.

Perhaps a template entity which uses an attribute instead will work better, but then it has to be self-referential, which causes:

Template loop detected while processing event
[...]
skipping template render for Template[...]

AFAIK there isn’t a way to “push” attributes into a template entry from e.g. an automation, right?

I’ve worked around the self-referential issue by creating two template sensors, one which has a time_template trigger and the second which bases its additions of current source/volume on that one:

template:
  - trigger:
      - platform: time_pattern
        seconds: "/5"
    sensor:
      - name: Living Room Media Player Source Volumes
        state: "voluming"
        attributes:
          volumes: |
            {{ state_attr('sensor.current_living_room_media_player', 'volumes') }}

  - sensor:
      - name: Current Living Room Media Player
        state: >-
          {# template elided but it basically returns the ID of another #}
          {# media_player which is used in the control of a `universal` player #}
        attributes:
          source: >-
            {{ state_attr(states('sensor.current_living_room_media_player'), 'source') }}
          volumes: >-
            {% set cached_volumes = state_attr('sensor.living_room_media_player_source_volumes', 'volumes') %}
            {% if cached_volumes in ["unknown", "unavailable", None, ""] %}
              {% set cached_volumes = [] %}
            {% endif %}

            {% set volumes = namespace(list= []) %}

            {# check multiple "sources" so we keep stream-specific volume but also streaming volume _generally_ as a fallback #}
            {% set sources = [
                state_attr('media_player.denon_avr_x1600h', 'source'),
                state_attr('media_player.denon_avr_x1600h_heos', 'source'),
                state_attr('sensor.current_living_room_media_player', 'source')
              ] | unique | list %}
            {% set level = state_attr('media_player.denon_avr_x1600h', 'volume_level') %}

            {# copy over volumes for other sources #}
            {% for v in cached_volumes %}
              {% if v.source != None and v.source not in sources %}
                {% set volumes.list = volumes.list + [v] %}
              {% endif %}
            {% endfor %}

            {# update volumes for current sources #}
            {% for source in sources %}
              {% if source != None %}
                {% set volumes.list = volumes.list + [{"source": source, "volume_level": level}] %}
              {% endif %}
            {% endfor %}

            {{ volumes.list }}

Now it should be relatively straight-forward to adjust the volume to the last known value.

If anybody has a way to merge the list into a single dictionary, that would be appreciated as it would simplify the lookup in my automation, but this should do the trick…

Hmm… for some reason restarting HA the volumes cache is lost.

I think the template is rendered before the recorder restores the state, meaning that it gets None from the cache, falling back to an [] which then gets saved as the latest attributes.

Guh! :frowning:

If you need to store complex objects to persist over a restart, you’d need to use MQTT or a series of input_numbers.

Anyways, here’s a dict

{% set cache  = state_attr('sensor.living_room_media_player_source_volumes', 'volumes') %}
{% if cache %}
  {% set players = expand('media_player.denon_avr_x1600h', 'media_player.denon_avr_x1600h_heos') %}
  {% set ns = namespace(updated = [], kvps=[]) %}
  {% for update in players | selectattr('source', 'in', cache.keys() | list) %}
    {% set ns.updated = ns.updated + [ (update.attributes.source | slugify, update.attributes.volume_level) ] %}
  {% endfor %}
  {% set updated = dict(ns.updated) %}
  {% if ns.updated %}
    {% for k, v in cache.items() %}
      {% set k = k | slugify %}
      {% set ns.kvps = ns.kvps + [ (k, updated.get(k, v)) ] %} 
    {% endfor %}
  {% endif %}
  {{ dict(ns.kvps) }}
{% endif %}

I did something like this recently, do snapshot scenes not include volume levels?

Seems like a perfect use case @petro

I was confused by that at first too, but he wants to store volumes based on source, not media player. Apparently different stations that he listens to have different volume levels

Ah got you. Scenes do work for volume though right? I only realised that after I did an overly complicated automation similar to above to store levels for different players :smiley: will rework it using snapshots at some point

I don’t think with scenes you can cherry-pick the attributes, though. I don’t want to restore all state (for instance, if it is spotify, I don’t want it to restore the media item that it was playing).

:man_facepalming: omg of course the answer is tuples. I am not a Python dev so it just didn’t occur to me (I tried lists of key-pair lists, but not lists of tuples). Perfect. Thank you!

As for the rest of the code you’ve provided, it’s doing some things I’m not familiar with so it’ll take some fiddling for me to fully grok, but it’s certainly enough for me to iterate on. Thanks!

I did think about input_numbers per source but it doesn’t really scale with the sources (which come from HEOS favourites, for the most part).

However, I do have Mosquitto set up for Z2M anyway, so MQTT could be an acceptable option. Are you thinking that each change would be a retained message and then on boot they are progressively read (last winning)? That would create a lot of messages I’d have to clear up I think but could work.

I’m only starting to get familiar with MQTT so I’ll have to think through it more but it’s a good idea for me to mull on. Thank you!

1 Like

:thinking: or perhaps a sql sensor to get the most recent non-empty attribute as the basis for layering new volumes. This would be instead of the second template sensor to break the recursion.

Hi, did anybody get this to work with MQTT or SQL ?