Add volume management to AVR
Deploy HEOS panel / deploy (push) Successful in 26s

This commit is contained in:
franzz committed 2026-09-20 18:18:28 +02:00
1 parent db00e57ef7
commit 9a3d0b8a72
6 files changed
+42 -19

No files matched your search

+2 -2
View File
@@ -6,7 +6,7 @@ A phone-sized web remote for a multi-room HEOS system, meant to be added to the
Everything fits on one screen: Everything fits on one screen:
- **Volume** up/down for each room. A tap lands on the next multiple of `VOLUME_STEP` — from 23 it goes to 25, not 28 — so the levels stay round. Hold to keep moving, or drag the level along its bar to set it outright. - **Volume** up/down for each room and for the AVR. A tap lands on the next multiple of `VOLUME_STEP` — from 23 it goes to 25, not 28 — so the levels stay round. Hold to keep moving, or drag the level along its bar to set it outright.
- **Navigate** either room (play / pause / prev / next), while it is playing Spotify - **Navigate** either room (play / pause / prev / next), while it is playing Spotify
- **Group** either room with the AVR. the AVR is always the host, so its sound takes over whatever joins it. A room that joins moves _into_ the Home Cinema card, so one glance says what is playing together - **Group** either room with the AVR. the AVR is always the host, so its sound takes over whatever joins it. A room that joins moves _into_ the Home Cinema card, so one glance says what is playing together
- **Ungroup** either room again, or all of them at once - **Ungroup** either room again, or all of them at once
@@ -138,7 +138,7 @@ Used by the interface:
| --- | --- | | --- | --- |
| `GET /api/state` | everything the UI draws, in one call | | `GET /api/state` | everything the UI draws, in one call |
| `GET /api/targets` | every player and group HEOS can see | | `GET /api/targets` | every player and group HEOS can see |
| `POST /api/volume` | `{"target": "lego_room", "steps": 1}` — taps, snapped to `VOLUME_STEP`. Also takes `delta` (raw points) or `level` (absolute) | | `POST /api/volume` | `{"target": "lego_room", "steps": 1}` — taps, snapped to `VOLUME_STEP`. Also takes `delta` (raw points) or `level` (absolute). `avr` is a target like any room |
| `POST /api/mute` | `{"target": "lego_room"}` | | `POST /api/mute` | `{"target": "lego_room"}` |
| `POST /api/playback` | `{"target": "lego_room", "state": "pause"}`, or no `state` to toggle — the Zidoo's film for the AVR (`play` and `pause` only), a room's own stream over HEOS otherwise | | `POST /api/playback` | `{"target": "lego_room", "state": "pause"}`, or no `state` to toggle — the Zidoo's film for the AVR (`play` and `pause` only), a room's own stream over HEOS otherwise |
| `POST /api/skip` | `{"target": "lego_room", "direction": "next"}` — `previous` too | | `POST /api/skip` | `{"target": "lego_room", "direction": "next"}` — `previous` too |
+23 -8
View File
@@ -138,10 +138,18 @@ class Controller:
def _id_param(scope: str) -> str: def _id_param(scope: str) -> str:
return "pid" if scope == "player" else "gid" return "pid" if scope == "player" else "gid"
def _read_volume(self, scope, obj_id) -> int: @staticmethod
reply = self.heos.command(f"{scope}/get_volume", **{self._id_param(scope): obj_id}) def _level_in(reply) -> int:
"""The level a get_volume reply carries, or None when there is no
answer to read -- a player that was never asked, or one whose reply
came back a HeosError."""
if reply is None or isinstance(reply, HeosError):
return None
return int(parse_message(reply["heos"]["message"]).get("level", -1)) return int(parse_message(reply["heos"]["message"]).get("level", -1))
def _read_volume(self, scope, obj_id) -> int:
return self._level_in(self.heos.command(f"{scope}/get_volume", **{self._id_param(scope): obj_id}))
def volume(self, key: str) -> int: def volume(self, key: str) -> int:
with self._lock: with self._lock:
self._fresh() self._fresh()
@@ -470,13 +478,13 @@ class Controller:
snapshot = { snapshot = {
"host": {"key": self.cfg.HOST_KEY, "label": self.cfg.TARGETS[self.cfg.HOST_KEY]["label"]}, "host": {"key": self.cfg.HOST_KEY, "label": self.cfg.TARGETS[self.cfg.HOST_KEY]["label"]},
"rooms": [], "rooms": [],
"avr": {"connected": False, "input": None, "inputs": []}, "avr": {"connected": False, "input": None, "inputs": [], "volume": None},
"heos_ok": True, "heos_ok": True,
"errors": [], "errors": [],
} }
host_pid = None host_pid = None
avr_now_playing = None avr_replies = {}
with self._lock: with self._lock:
try: try:
self.scan() self.scan()
@@ -502,14 +510,19 @@ class Controller:
] ]
request_keys += [(key, "volume"), (key, "play_state"), (key, "now_playing")] request_keys += [(key, "volume"), (key, "play_state"), (key, "now_playing")]
if host_pid is not None: if host_pid is not None:
requests.append(("player/get_now_playing_media", {"pid": host_pid})) # The AVR is a player like any other, so its own volume
request_keys.append((None, "avr_now_playing")) # rides along in the same batch as the rooms'.
requests += [
("player/get_volume", {"pid": host_pid}),
("player/get_now_playing_media", {"pid": host_pid}),
]
request_keys += [(None, "volume"), (None, "now_playing")]
results = self.heos.command_batch(requests) if requests else [] results = self.heos.command_batch(requests) if requests else []
by_room = {} by_room = {}
for (key, field), result in zip(request_keys, results): for (key, field), result in zip(request_keys, results):
if key is None: if key is None:
avr_now_playing = result avr_replies[field] = result
else: else:
by_room.setdefault(key, {})[field] = result by_room.setdefault(key, {})[field] = result
@@ -533,7 +546,7 @@ class Controller:
volume_reply = data.get("volume") volume_reply = data.get("volume")
if isinstance(volume_reply, HeosError): if isinstance(volume_reply, HeosError):
raise volume_reply raise volume_reply
room["volume"] = int(parse_message(volume_reply["heos"]["message"]).get("level", -1)) room["volume"] = self._level_in(volume_reply)
play_reply = data.get("play_state") play_reply = data.get("play_state")
if isinstance(play_reply, HeosError): if isinstance(play_reply, HeosError):
@@ -569,6 +582,7 @@ class Controller:
try: try:
current_input = None current_input = None
avr_now_playing = avr_replies.get("now_playing")
if host_pid is not None and avr_now_playing is not None and not isinstance(avr_now_playing, HeosError): if host_pid is not None and avr_now_playing is not None and not isinstance(avr_now_playing, HeosError):
payload = avr_now_playing.get("payload") or {} payload = avr_now_playing.get("payload") or {}
mid = payload.get("mid", "") mid = payload.get("mid", "")
@@ -578,6 +592,7 @@ class Controller:
"connected": host_pid is not None, "connected": host_pid is not None,
"inputs": self.avr_inputs(), "inputs": self.avr_inputs(),
"input": current_input, "input": current_input,
"volume": self._level_in(avr_replies.get("volume")),
"now_playing": self.zidoo_now_playing(current_input), "now_playing": self.zidoo_now_playing(current_input),
} }
except (HeosError, TargetError) as exc: except (HeosError, TargetError) as exc:
+1
View File
@@ -79,6 +79,7 @@ class DemoController:
], ],
"avr": { "avr": {
"connected": True, "inputs": self.avr.inputs(), "input": self.avr.current_input(), "connected": True, "inputs": self.avr.inputs(), "input": self.avr.current_input(),
"volume": self._volume[self.cfg.HOST_KEY],
# Standing in for a Zidoo plugged into this input, so the # Standing in for a Zidoo plugged into this input, so the
# AVR card's now-playing block has something to show here too. # AVR card's now-playing block has something to show here too.
"now_playing": {"song": "Big Buck Bunny", "artist": "2008", "image": None, "now_playing": {"song": "Big Buck Bunny", "artist": "2008", "image": None,
+2 -7
View File
@@ -151,7 +151,7 @@ els('.card[data-room]').forEach((node) => {
const direction = Number(button.dataset.delta); const direction = Number(button.dataset.delta);
holdable(button, () => nudge(key, direction)); holdable(button, () => nudge(key, direction));
}); });
if (room.meter) draggable(room); draggable(room);
seekable(room); seekable(room);
// A cover that will not load (gone, or plain http on an https page) is // A cover that will not load (gone, or plain http on an https page) is
@@ -165,10 +165,7 @@ els('.card[data-room]').forEach((node) => {
if (room.next) room.next.addEventListener('click', () => skipTrack(key, 'next')); if (room.next) room.next.addEventListener('click', () => skipTrack(key, 'next'));
}); });
/* The volume row, which the AVR's card has not got: its own level is not in
/api/state, so there is nothing to draw a meter from. */
function paintVolume(room) { function paintVolume(room) {
if (!room.meter) return;
const known = room.volume !== null && room.volume !== undefined; const known = room.volume !== null && room.volume !== undefined;
room.level.textContent = known ? room.volume : '—'; room.level.textContent = known ? room.volume : '—';
// The fill and the knob riding it both size themselves off this. // The fill and the knob riding it both size themselves off this.
@@ -191,9 +188,6 @@ function paintTransport(room) {
if (room.next) room.next.disabled = !room.available; if (room.next) room.next.disabled = !room.available;
} }
/* The Spotify buttons and the host toggle, which the AVR's card has not got:
those resume an account in a room, or join it to the AVR, and neither is
something the AVR does to itself. */
function paintSources(room) { function paintSources(room) {
if (!room.sources) return; if (!room.sources) return;
// Grouped, the room plays whatever the AVR does, so resuming Spotify on it // Grouped, the room plays whatever the AVR does, so resuming Spotify on it
@@ -787,6 +781,7 @@ function render(state) {
// play state is that device's -- HEOS has none to give for an input. // play state is that device's -- HEOS has none to give for an input.
applyRoom(rooms[HOST], { applyRoom(rooms[HOST], {
available: avr.connected !== false, available: avr.connected !== false,
volume: avr.volume,
now_playing: avr.now_playing || null, now_playing: avr.now_playing || null,
play_state: (avr.now_playing || {}).play_state || null, play_state: (avr.now_playing || {}).play_state || null,
}); });
-2
View File
@@ -83,7 +83,6 @@
</div> </div>
</div> </div>
{% if not avr %}
<div class="volume"> <div class="volume">
<button class="control-btn step" data-delta="-1" aria-label="{{ room.label }}: volume down"> <button class="control-btn step" data-delta="-1" aria-label="{{ room.label }}: volume down">
<svg viewBox="0 0 448 512" aria-hidden="true"><path d="M432 256c0 17.7-14.3 32-32 32L48 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l352 0c17.7 0 32 14.3 32 32z"/></svg> <svg viewBox="0 0 448 512" aria-hidden="true"><path d="M432 256c0 17.7-14.3 32-32 32L48 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l352 0c17.7 0 32 14.3 32 32z"/></svg>
@@ -93,7 +92,6 @@
<svg viewBox="0 0 448 512" aria-hidden="true"><path d="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 144L48 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l144 0 0 144c0 17.7 14.3 32 32 32s32-14.3 32-32l0-144 144 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-144 0 0-144z"/></svg> <svg viewBox="0 0 448 512" aria-hidden="true"><path d="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 144L48 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l144 0 0 144c0 17.7 14.3 32 32 32s32-14.3 32-32l0-144 144 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-144 0 0-144z"/></svg>
</button> </button>
</div> </div>
{% endif %}
<div class="actions" data-role="actions" hidden> <div class="actions" data-role="actions" hidden>
{% if not avr %} {% if not avr %}
+14
View File
@@ -240,6 +240,20 @@ class PanelTest(unittest.TestCase):
self.assertTrue(all(isinstance(r["volume"], int) for r in state["rooms"])) self.assertTrue(all(isinstance(r["volume"], int) for r in state["rooms"]))
self.assertEqual([r["play_state"] for r in state["rooms"]], ["play", "play"]) self.assertEqual([r["play_state"] for r in state["rooms"]], ["play", "play"])
def test_state_carries_the_avrs_own_volume(self):
"""The AVR's card has a level like every other, and it is the
player's own -- not the group's, even while it leads one."""
self.heos.volumes[AVR_PID] = 37
self.panel.join("lego_room")
self.assertEqual(self.panel.state()["avr"]["volume"], 37)
def test_the_avrs_volume_moves_like_a_rooms(self):
self.heos.volumes[AVR_PID] = 23
self.assertEqual(self.panel.step_volume("avr", 1), 25)
self.assertEqual(self.heos.volumes[AVR_PID], 25)
self.assertEqual(self.panel.set_volume("avr", 40), 40)
self.assertEqual(self.panel.volume("avr"), 40)
def test_state_reports_trouble_instead_of_blowing_up(self): def test_state_reports_trouble_instead_of_blowing_up(self):
self.panel.heos.host = "127.0.0.1" self.panel.heos.host = "127.0.0.1"
self.panel.heos.port = 1 # nothing is listening there self.panel.heos.port = 1 # nothing is listening there