@@ -9,6 +9,9 @@ Everything it does fits on one screen:
|
|||||||
- **Volume** up/down for the Home 400 and the Living Room pair. A tap lands
|
- **Volume** up/down for the Home 400 and the Living Room pair. A tap lands
|
||||||
on the next multiple of `VOLUME_STEP` — from 23 it goes to 25, not 28 —
|
on the next multiple of `VOLUME_STEP` — from 23 it goes to 25, not 28 —
|
||||||
so the levels stay round. Hold to keep moving.
|
so the levels stay round. Hold to keep moving.
|
||||||
|
- **Play or pause** either room. A room that is grouped shares the AVR's
|
||||||
|
transport, so pausing it pauses the group — HEOS's doing, not the panel's:
|
||||||
|
a group has one thing playing, by definition
|
||||||
- **Group** either room with the AVR — the AVR is always the host, so its
|
- **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
|
sound takes over whatever joins it. A room that joins moves *into* the
|
||||||
Home Cinema card, so one glance says what is playing together
|
Home Cinema card, so one glance says what is playing together
|
||||||
@@ -257,6 +260,7 @@ Used by the interface:
|
|||||||
| `GET /api/targets` | every player and group HEOS can see |
|
| `GET /api/targets` | every player and group HEOS can see |
|
||||||
| `POST /api/volume` | `{"target": "home400", "steps": 1}` — taps, snapped to `VOLUME_STEP`. Also takes `delta` (raw points) or `level` (absolute) |
|
| `POST /api/volume` | `{"target": "home400", "steps": 1}` — taps, snapped to `VOLUME_STEP`. Also takes `delta` (raw points) or `level` (absolute) |
|
||||||
| `POST /api/mute` | `{"target": "home400"}` |
|
| `POST /api/mute` | `{"target": "home400"}` |
|
||||||
|
| `POST /api/playback` | `{"target": "home400", "state": "pause"}`, or no `state` to toggle |
|
||||||
| `POST /api/group` | `{"target": "home400", "joined": true}` |
|
| `POST /api/group` | `{"target": "home400", "joined": true}` |
|
||||||
| `POST /api/group/none` | every room back on its own |
|
| `POST /api/group/none` | every room back on its own |
|
||||||
| `GET /api/avr/inputs` | your renamed sources |
|
| `GET /api/avr/inputs` | your renamed sources |
|
||||||
|
|||||||
@@ -136,6 +136,19 @@ def api_mute():
|
|||||||
return jsonify({"target": key, "ok": True})
|
return jsonify({"target": key, "ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/playback")
|
||||||
|
@handle_errors
|
||||||
|
def api_playback():
|
||||||
|
"""Start or stop a room. Send 'state' to be explicit, or leave it out to
|
||||||
|
flip whatever the speakers are actually doing."""
|
||||||
|
data = _payload()
|
||||||
|
key = _target_from(data)
|
||||||
|
state = data.get("state")
|
||||||
|
if state is not None and state not in ("play", "pause", "stop"):
|
||||||
|
raise ValueError("'state' must be play, pause or stop -- or left out to toggle")
|
||||||
|
return jsonify({"target": key, "state": controller.toggle_play(key, state)})
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/group")
|
@app.post("/api/group")
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def api_group():
|
def api_group():
|
||||||
|
|||||||
+28
-1
@@ -346,6 +346,31 @@ class Controller:
|
|||||||
self._fresh()
|
self._fresh()
|
||||||
self.heos.command("player/set_play_state", pid=self.playback_pid(key), state=state)
|
self.heos.command("player/set_play_state", pid=self.playback_pid(key), state=state)
|
||||||
|
|
||||||
|
def get_play_state(self, key: str) -> str:
|
||||||
|
""""play", "pause" or "stop" for whatever this room is doing."""
|
||||||
|
with self._lock:
|
||||||
|
self._fresh()
|
||||||
|
reply = self.heos.command("player/get_play_state", pid=self.playback_pid(key))
|
||||||
|
return parse_message(reply["heos"]["message"]).get("state", "stop")
|
||||||
|
|
||||||
|
def toggle_play(self, key: str, state: str = None) -> str:
|
||||||
|
"""Start or stop a room. With no state, flips whatever it is doing
|
||||||
|
now -- read from the speakers rather than trusted from the phone,
|
||||||
|
whose copy can be a few seconds old.
|
||||||
|
|
||||||
|
A room that is grouped shares the AVR's transport, so this stops the
|
||||||
|
whole group. That is HEOS's doing, not ours: a group has one thing
|
||||||
|
playing, by definition.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self._fresh()
|
||||||
|
if state is None:
|
||||||
|
state = "pause" if self.get_play_state(key) == "play" else "play"
|
||||||
|
self.heos.command(
|
||||||
|
"player/set_play_state", pid=self.playback_pid(key), state=state
|
||||||
|
)
|
||||||
|
return state
|
||||||
|
|
||||||
def skip(self, key: str, direction: str):
|
def skip(self, key: str, direction: str):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._fresh()
|
self._fresh()
|
||||||
@@ -388,11 +413,13 @@ class Controller:
|
|||||||
"available": True,
|
"available": True,
|
||||||
"grouped": key in joined,
|
"grouped": key in joined,
|
||||||
"volume": None,
|
"volume": None,
|
||||||
|
"play_state": None,
|
||||||
"error": None,
|
"error": None,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
scope, obj_id = self._volume_handles(key)[0]
|
scope, obj_id = self._volume_handles(key)[0]
|
||||||
room["volume"] = self._read_volume(scope, obj_id)
|
room["volume"] = self._read_volume(scope, obj_id)
|
||||||
|
room["play_state"] = self.get_play_state(key)
|
||||||
except (TargetError, HeosError, KeyError) as exc:
|
except (TargetError, HeosError, KeyError) as exc:
|
||||||
room["available"] = False
|
room["available"] = False
|
||||||
room["error"] = str(exc)
|
room["error"] = str(exc)
|
||||||
@@ -402,7 +429,7 @@ class Controller:
|
|||||||
snapshot["errors"].append(str(exc))
|
snapshot["errors"].append(str(exc))
|
||||||
snapshot["rooms"] = [
|
snapshot["rooms"] = [
|
||||||
{"key": key, "label": self.cfg.TARGETS[key]["label"], "available": False,
|
{"key": key, "label": self.cfg.TARGETS[key]["label"], "available": False,
|
||||||
"grouped": False, "volume": None, "error": str(exc)}
|
"grouped": False, "volume": None, "play_state": None, "error": str(exc)}
|
||||||
for key in self.cfg.ROOM_KEYS
|
for key in self.cfg.ROOM_KEYS
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class DemoController:
|
|||||||
self.avr = _FakeAvr(cfg.AVR_INPUT_CODES)
|
self.avr = _FakeAvr(cfg.AVR_INPUT_CODES)
|
||||||
self.heos = None
|
self.heos = None
|
||||||
self._volume = {key: 22 + 7 * i for i, key in enumerate(cfg.TARGETS)}
|
self._volume = {key: 22 + 7 * i for i, key in enumerate(cfg.TARGETS)}
|
||||||
|
self._play = {key: "play" for key in cfg.TARGETS}
|
||||||
self._joined = set()
|
self._joined = set()
|
||||||
|
|
||||||
# -- what the UI uses ---------------------------------------------
|
# -- what the UI uses ---------------------------------------------
|
||||||
@@ -57,7 +58,8 @@ class DemoController:
|
|||||||
"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": [
|
||||||
{"key": key, "label": self.cfg.TARGETS[key]["label"], "available": True,
|
{"key": key, "label": self.cfg.TARGETS[key]["label"], "available": True,
|
||||||
"grouped": key in self._joined, "volume": self._volume[key], "error": None}
|
"grouped": key in self._joined, "volume": self._volume[key],
|
||||||
|
"play_state": self._play[key], "error": None}
|
||||||
for key in self.cfg.ROOM_KEYS
|
for key in self.cfg.ROOM_KEYS
|
||||||
],
|
],
|
||||||
"avr": {"connected": True, "inputs": self.avr.inputs(), "input": self.avr.current_input()},
|
"avr": {"connected": True, "inputs": self.avr.inputs(), "input": self.avr.current_input()},
|
||||||
@@ -82,6 +84,15 @@ class DemoController:
|
|||||||
def toggle_mute(self, key):
|
def toggle_mute(self, key):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_play_state(self, key):
|
||||||
|
return self._play[key]
|
||||||
|
|
||||||
|
def toggle_play(self, key, state=None):
|
||||||
|
if state is None:
|
||||||
|
state = "pause" if self._play[key] == "play" else "play"
|
||||||
|
self._play[key] = state
|
||||||
|
return state
|
||||||
|
|
||||||
def join(self, key):
|
def join(self, key):
|
||||||
self._joined.add(key)
|
self._joined.add(key)
|
||||||
return self.joined_keys()
|
return self.joined_keys()
|
||||||
|
|||||||
+34
-2
@@ -60,13 +60,16 @@ els('.room').forEach((node) => {
|
|||||||
bar: el('[data-role="bar"]', node),
|
bar: el('[data-role="bar"]', node),
|
||||||
toggle: el('[data-role="group"]', node),
|
toggle: el('[data-role="group"]', node),
|
||||||
toggleLabel: el('[data-role="group-label"]', node),
|
toggleLabel: el('[data-role="group-label"]', node),
|
||||||
|
play: el('[data-role="play"]', node),
|
||||||
steps: els('.step', node),
|
steps: els('.step', node),
|
||||||
volume: null,
|
volume: null,
|
||||||
|
playState: null,
|
||||||
grouped: false,
|
grouped: false,
|
||||||
available: false,
|
available: false,
|
||||||
taps: 0, // button presses not yet sent
|
taps: 0, // button presses not yet sent
|
||||||
inflight: false,
|
inflight: false,
|
||||||
busy: false, // a grouping change is in flight
|
busy: false, // a grouping change is in flight
|
||||||
|
playBusy: false,
|
||||||
};
|
};
|
||||||
rooms[key] = room;
|
rooms[key] = room;
|
||||||
|
|
||||||
@@ -76,14 +79,21 @@ els('.room').forEach((node) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
room.toggle.addEventListener('click', () => setGrouped(key, !room.grouped));
|
room.toggle.addEventListener('click', () => setGrouped(key, !room.grouped));
|
||||||
|
room.play.addEventListener('click', () => togglePlay(key));
|
||||||
});
|
});
|
||||||
|
|
||||||
function paintRoom(room) {
|
function paintRoom(room) {
|
||||||
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 + '%' : '—';
|
||||||
room.bar.style.width = `${known ? room.volume : 0}%`;
|
room.bar.style.width = `${known ? room.volume : 0}%`;
|
||||||
room.node.classList.toggle('offline', !room.available);
|
room.node.classList.toggle('offline', !room.available);
|
||||||
room.steps.forEach((button) => { button.disabled = !room.available; });
|
room.steps.forEach((button) => { button.disabled = !room.available; });
|
||||||
|
const playing = room.playState === 'play';
|
||||||
|
room.play.classList.toggle('playing', playing);
|
||||||
|
room.play.disabled = !room.available || room.playState === null;
|
||||||
|
room.play.setAttribute(
|
||||||
|
'aria-label', `${room.node.querySelector('h2').textContent}: ${playing ? 'pause' : 'play'}`);
|
||||||
|
|
||||||
room.toggle.disabled = !room.available;
|
room.toggle.disabled = !room.available;
|
||||||
room.toggle.classList.toggle('busy', room.busy);
|
room.toggle.classList.toggle('busy', room.busy);
|
||||||
room.toggle.setAttribute('aria-pressed', String(room.grouped));
|
room.toggle.setAttribute('aria-pressed', String(room.grouped));
|
||||||
@@ -195,6 +205,26 @@ async function setGrouped(key, joined) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sends the state it wants rather than "toggle", so a stale idea of what
|
||||||
|
the room is doing cannot flip it the wrong way. */
|
||||||
|
async function togglePlay(key) {
|
||||||
|
const room = rooms[key];
|
||||||
|
if (!room.available || room.playBusy) return;
|
||||||
|
const wanted = room.playState === 'play' ? 'pause' : 'play';
|
||||||
|
room.playBusy = true;
|
||||||
|
room.playState = wanted;
|
||||||
|
paintRoom(room);
|
||||||
|
try {
|
||||||
|
const data = await api('/api/playback', { target: key, state: wanted });
|
||||||
|
room.playState = data.state;
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message);
|
||||||
|
} finally {
|
||||||
|
room.playBusy = false;
|
||||||
|
paintRoom(room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function applyJoined(joined) {
|
function applyJoined(joined) {
|
||||||
Object.values(rooms).forEach((room) => {
|
Object.values(rooms).forEach((room) => {
|
||||||
room.grouped = joined.includes(room.key);
|
room.grouped = joined.includes(room.key);
|
||||||
@@ -266,6 +296,7 @@ function render(state) {
|
|||||||
room.grouped = incoming.grouped;
|
room.grouped = incoming.grouped;
|
||||||
// Do not stomp on a volume the user is in the middle of changing.
|
// Do not stomp on a volume the user is in the middle of changing.
|
||||||
if (!room.taps && !room.inflight) room.volume = incoming.volume;
|
if (!room.taps && !room.inflight) room.volume = incoming.volume;
|
||||||
|
if (!room.playBusy) room.playState = incoming.play_state;
|
||||||
paintRoom(room);
|
paintRoom(room);
|
||||||
});
|
});
|
||||||
paintGrouping();
|
paintGrouping();
|
||||||
@@ -297,7 +328,8 @@ async function refresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function busy() {
|
function busy() {
|
||||||
return sheetOpen() || Object.values(rooms).some((r) => r.taps || r.inflight || r.busy);
|
return sheetOpen()
|
||||||
|
|| Object.values(rooms).some((r) => r.taps || r.inflight || r.busy || r.playBusy);
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.refresh.addEventListener('click', () => {
|
ui.refresh.addEventListener('click', () => {
|
||||||
|
|||||||
@@ -118,6 +118,32 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
|
|||||||
.meter { flex: 1; height: 8px; border-radius: 999px; background: #0c111b; overflow: hidden; }
|
.meter { flex: 1; height: 8px; border-radius: 999px; background: #0c111b; overflow: hidden; }
|
||||||
.meter i { display: block; height: 100%; width: 0; border-radius: 999px; background: var(--accent); transition: width .12s ease-out; }
|
.meter i { display: block; height: 100%; width: 0; border-radius: 999px; background: var(--accent); transition: width .12s ease-out; }
|
||||||
|
|
||||||
|
/* --- play / pause, beside the join button ------------------------------ */
|
||||||
|
.actions { display: flex; align-items: stretch; gap: 10px; }
|
||||||
|
.actions .toggle { flex: 1; }
|
||||||
|
|
||||||
|
.transport {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 66px; height: 52px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--raised);
|
||||||
|
display: grid; place-items: center;
|
||||||
|
}
|
||||||
|
.transport:active { background: var(--accent); transform: scale(.97); }
|
||||||
|
.transport:disabled { opacity: .5; }
|
||||||
|
.transport svg { width: 19px; height: 19px; }
|
||||||
|
/* A play triangle wants filling; the pause bars are drawn with the stroke
|
||||||
|
everything else in here uses. */
|
||||||
|
.transport .icon-play { fill: currentColor; stroke: none; }
|
||||||
|
.transport .icon-pause { stroke-width: 2.4; }
|
||||||
|
|
||||||
|
/* Which icon shows is a class on the button, not `hidden` on the svg:
|
||||||
|
`hidden` is an HTMLElement property and SVGElement does not inherit it,
|
||||||
|
so svg.hidden = true sets a JS expando and styles nothing. */
|
||||||
|
.transport .icon-pause { display: none; }
|
||||||
|
.transport.playing .icon-play { display: none; }
|
||||||
|
.transport.playing .icon-pause { display: block; }
|
||||||
|
|
||||||
/* --- join / leave the AVR -------------------------------------------- */
|
/* --- join / leave the AVR -------------------------------------------- */
|
||||||
.toggle {
|
.toggle {
|
||||||
height: 52px; border-radius: 16px;
|
height: 52px; border-radius: 16px;
|
||||||
@@ -165,6 +191,7 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
|
|||||||
background: none; border: 1px solid var(--edge); color: var(--muted);
|
background: none; border: 1px solid var(--edge); color: var(--muted);
|
||||||
}
|
}
|
||||||
.joined .toggle .dot { display: none; }
|
.joined .toggle .dot { display: none; }
|
||||||
|
.joined .transport { height: 40px; width: 58px; }
|
||||||
.joined .toggle:active { background: var(--raised); color: var(--ink); }
|
.joined .toggle:active { background: var(--raised); color: var(--ink); }
|
||||||
|
|
||||||
/* --- misc ------------------------------------------------------------- */
|
/* --- misc ------------------------------------------------------------- */
|
||||||
|
|||||||
+11
-4
@@ -58,10 +58,17 @@
|
|||||||
<button class="step" data-delta="1" aria-label="{{ room.label }}: volume up">+</button>
|
<button class="step" data-delta="1" aria-label="{{ room.label }}: volume up">+</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="toggle" data-role="group" aria-pressed="false">
|
<div class="actions">
|
||||||
<span class="dot" aria-hidden="true"></span>
|
<button class="transport" data-role="play" aria-label="{{ room.label }}: play">
|
||||||
<span data-role="group-label">Separate</span>
|
<svg class="icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5l11 7-11 7z"/></svg>
|
||||||
</button>
|
<svg class="icon-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5v14M15 5v14"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="toggle" data-role="group" aria-pressed="false">
|
||||||
|
<span class="dot" aria-hidden="true"></span>
|
||||||
|
<span data-role="group-label">Separate</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class FakeHeos(threading.Thread):
|
|||||||
super().__init__(daemon=True)
|
super().__init__(daemon=True)
|
||||||
self.groups = {3: [3, 4]} # gid -> pids, leader first
|
self.groups = {3: [3, 4]} # gid -> pids, leader first
|
||||||
self.volumes = {pid: 20 for pid in self.NAMES}
|
self.volumes = {pid: 20 for pid in self.NAMES}
|
||||||
|
self.play_states = {pid: "play" for pid in self.NAMES}
|
||||||
self.group_volumes = {3: 25}
|
self.group_volumes = {3: 25}
|
||||||
self.commands = [] # everything we were asked to do
|
self.commands = [] # everything we were asked to do
|
||||||
self.server = socket.socket()
|
self.server = socket.socket()
|
||||||
@@ -96,6 +97,15 @@ class FakeHeos(threading.Thread):
|
|||||||
store[key[1]] = int(args["level"])
|
store[key[1]] = int(args["level"])
|
||||||
return self._ok(path, message=f"{key[0]}={key[1]}&level={args['level']}")
|
return self._ok(path, message=f"{key[0]}={key[1]}&level={args['level']}")
|
||||||
|
|
||||||
|
if path == "player/get_play_state":
|
||||||
|
pid = int(args["pid"])
|
||||||
|
return self._ok(path, message=f"pid={pid}&state={self.play_states[pid]}")
|
||||||
|
|
||||||
|
if path == "player/set_play_state":
|
||||||
|
pid = int(args["pid"])
|
||||||
|
self.play_states[pid] = args["state"]
|
||||||
|
return self._ok(path, message=f"pid={pid}&state={args['state']}")
|
||||||
|
|
||||||
if path.endswith("/toggle_mute") or path == "system/heart_beat":
|
if path.endswith("/toggle_mute") or path == "system/heart_beat":
|
||||||
return self._ok(path)
|
return self._ok(path)
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,26 @@ class PanelTest(unittest.TestCase):
|
|||||||
self.panel.set_volume("living_room_group", 23)
|
self.panel.set_volume("living_room_group", 23)
|
||||||
self.assertEqual(self.panel.step_volume("living_room_group", 3), 35)
|
self.assertEqual(self.panel.step_volume("living_room_group", 3), 35)
|
||||||
|
|
||||||
|
# -- play / pause ------------------------------------------------------
|
||||||
|
def test_toggle_play_flips_what_the_speakers_report(self):
|
||||||
|
self.panel.scan()
|
||||||
|
self.assertEqual(self.panel.get_play_state("home400"), "play")
|
||||||
|
self.assertEqual(self.panel.toggle_play("home400"), "pause")
|
||||||
|
self.assertEqual(self.panel.get_play_state("home400"), "pause")
|
||||||
|
self.assertEqual(self.panel.toggle_play("home400"), "play")
|
||||||
|
|
||||||
|
def test_toggle_play_takes_an_explicit_state(self):
|
||||||
|
self.panel.scan()
|
||||||
|
self.assertEqual(self.panel.toggle_play("home400", "stop"), "stop")
|
||||||
|
self.assertEqual(self.heos.play_states[HOME400_PID], "stop")
|
||||||
|
|
||||||
|
def test_pair_is_controlled_through_one_of_its_speakers(self):
|
||||||
|
"""Playback is a player command -- a group has none of its own -- so
|
||||||
|
it goes to the pair's leader."""
|
||||||
|
self.panel.scan()
|
||||||
|
self.panel.toggle_play("living_room_group", "pause")
|
||||||
|
self.assertEqual(self.heos.play_states[3], "pause")
|
||||||
|
|
||||||
# -- the AVR ---------------------------------------------------------
|
# -- the AVR ---------------------------------------------------------
|
||||||
def test_renamed_inputs_and_selection(self):
|
def test_renamed_inputs_and_selection(self):
|
||||||
deadline = time.time() + 5
|
deadline = time.time() + 5
|
||||||
@@ -176,6 +196,7 @@ class PanelTest(unittest.TestCase):
|
|||||||
self.assertEqual([r["key"] for r in state["rooms"]], ["home400", "living_room_group"])
|
self.assertEqual([r["key"] for r in state["rooms"]], ["home400", "living_room_group"])
|
||||||
self.assertEqual([r["grouped"] for r in state["rooms"]], [True, False])
|
self.assertEqual([r["grouped"] for r in state["rooms"]], [True, False])
|
||||||
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"])
|
||||||
|
|
||||||
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user