From 8b9633e857c96122275ecfb79f41d78d5c1bd279 Mon Sep 17 00:00:00 2001 From: Tom Ernst Date: Fri, 11 Sep 2026 23:59:34 +0200 Subject: [PATCH] [verified] feat: add equalizer controls --- app/sonos.py | 17 ++++++++++- app/static/app.js | 31 +++++++++++++++++++- app/static/style.css | 18 ++++++++++++ app/web.py | 6 ++++ tests/test_app.py | 70 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 2 deletions(-) diff --git a/app/sonos.py b/app/sonos.py index 8a99e0d..94990c7 100644 --- a/app/sonos.py +++ b/app/sonos.py @@ -111,6 +111,8 @@ class SonosService: return { "uid": self._uid(zone), "name": zone.player_name, "ip": str(zone.ip_address), "volume": int(zone.volume), + "bass": int(zone.bass), "treble": int(zone.treble), + "loudness": bool(zone.loudness), "state": info.get("current_transport_state", "UNKNOWN"), "track": { "title": track.get("title") or "", "artist": track.get("artist") or "", @@ -139,7 +141,8 @@ class SonosService: results[self._uid(zone)] = { "uid": self._uid(zone), "name": str(zone.player_name), "ip": str(zone.ip_address), "state": "UNAVAILABLE", - "volume": None, "track": {}, "coordinatorUid": self._uid(zone), + "volume": None, "bass": None, "treble": None, "loudness": None, + "track": {}, "coordinatorUid": self._uid(zone), "memberUids": [self._uid(zone)], "error": str(self._communication_error(exc)), } return [results[self._uid(zone)] for zone in zones] @@ -156,6 +159,18 @@ class SonosService: zone = self._zone(uid) self._call(lambda: setattr(zone, "volume", volume)) + def set_eq(self, uid: str, control: str, value: Any) -> None: + if control in {"bass", "treble"}: + if isinstance(value, bool) or not isinstance(value, int) or not -10 <= value <= 10: + raise ValueError("Bass und Höhen müssen ganze Zahlen zwischen -10 und 10 sein.") + elif control == "loudness": + if not isinstance(value, bool): + raise ValueError("Loudness muss ein boolescher Wert sein.") + else: + raise ValueError("Unbekannter Equalizer-Regler.") + zone = self._zone(uid) + self._call(lambda: setattr(zone, control, value)) + def group(self, coordinator_uid: str, member_uids: list[str]) -> None: if not member_uids: raise ValueError("Mindestens ein Gruppenmitglied ist erforderlich.") diff --git a/app/static/app.js b/app/static/app.js index 6ae8c5d..71ee320 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -1,5 +1,6 @@ let zones = [], source = 'favorites'; let zoneRequest = 0, zoneController = null, musicRequest = 0, musicController = null; +const pendingEq = new Set(); const $ = selector => document.querySelector(selector); const el = (tag, cls, text) => { const node = document.createElement(tag); if (cls) node.className = cls; if (text !== undefined) node.textContent = text; return node; }; @@ -24,6 +25,21 @@ async function act(url, method = 'POST', data) { try { notice(); await api(url, {method, body: data === undefined ? undefined : JSON.stringify(data)}); await load(false); } catch (error) { notice(error.message); } } +async function setEq(uid, control, value, input) { + const key = `${uid}:${control}`; + if (pendingEq.has(key)) return; + pendingEq.add(key); input.disabled = true; + try { + notice(); + await api(`/api/zones/${encodeURIComponent(uid)}/eq`, {method:'PUT', body:JSON.stringify({control, value})}); + pendingEq.delete(key); + await load(false); + } catch (error) { + pendingEq.delete(key); + await load(false); + notice(error.message); + } +} function option(value, text) { const item = el('option', null, text); item.value = value; return item; } function stateName(state) { return ({PLAYING:'WIEDERGABE', PAUSED_PLAYBACK:'PAUSIERT', STOPPED:'GESTOPPT', TRANSITIONING:'WECHSEL', UNAVAILABLE:'NICHT ERREICHBAR'})[state] || state; } @@ -43,8 +59,21 @@ function render() { slider.type = 'range'; slider.min = 0; slider.max = 100; slider.value = zone.volume ?? 0; slider.disabled = zone.volume === null; slider.oninput = () => volume.firstChild.textContent = `Lautstärke: ${slider.value}`; slider.onchange = () => act(`/api/zones/${encodeURIComponent(zone.uid)}/volume`, 'PUT', {volume:Number(slider.value)}); volume.append(slider); + const eq = el('div', 'eq-controls'); + for (const [control, label, value] of [['bass', 'Bass', zone.bass], ['treble', 'Höhen', zone.treble]]) { + const field = el('label', null, `${label}: ${value ?? '–'}`), range = el('input'); + range.type = 'range'; range.min = -10; range.max = 10; range.value = value ?? 0; range.disabled = value === null || pendingEq.has(`${zone.uid}:${control}`); + range.oninput = () => field.firstChild.textContent = `${label}: ${Number(range.value) > 0 ? '+' : ''}${range.value}`; + range.onchange = () => setEq(zone.uid, control, Number(range.value), range); + field.append(range); eq.append(field); + } + const loudness = el('button', `loudness${zone.loudness ? ' active' : ''}`, `Loudness: ${zone.loudness ? 'Ein' : 'Aus'}`); + loudness.dataset.value = String(zone.loudness); + loudness.disabled = zone.loudness === null || pendingEq.has(`${zone.uid}:loudness`); + loudness.onclick = () => setEq(zone.uid, 'loudness', loudness.dataset.value !== 'true', loudness); + eq.append(loudness); const leave = el('button', 'danger', 'Aus Gruppe lösen'); leave.hidden = zone.memberUids.length < 2; - leave.onclick = () => act(`/api/groups/${encodeURIComponent(zone.uid)}`, 'DELETE'); card.append(head, track, controls, volume, leave); root.append(card); + leave.onclick = () => act(`/api/groups/${encodeURIComponent(zone.uid)}`, 'DELETE'); card.append(head, track, controls, volume, eq, leave); root.append(card); } renderSelects(); loadMusic(); } diff --git a/app/static/style.css b/app/static/style.css index fdb7ca7..956aa0e 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -119,6 +119,24 @@ button:hover, button:focus { .controls button { flex: 1; } .danger { color: var(--danger); } +.eq-controls { + display: grid; + gap: 4px; + margin-top: 5px; +} + +.loudness { + justify-self: start; + min-height: 26px; + padding: 3px 9px; + font-size: .76rem; +} + +.loudness.active { + border-color: var(--pink); + background: linear-gradient(110deg, #7c3aed, var(--pink)); +} + label { display: grid; gap: 4px; diff --git a/app/web.py b/app/web.py index 8b3d8e2..fcccf7d 100644 --- a/app/web.py +++ b/app/web.py @@ -59,6 +59,12 @@ def create_app(config: dict[str, Any] | None = None, service: SonosService | Non sonos.set_volume(uid, data.get("volume")) return jsonify(ok=True) + @app.put("/api/zones//eq") + def equalizer(uid: str): + data = body() + sonos.set_eq(uid, required_string(data, "control"), data.get("value")) + return jsonify(ok=True) + @app.post("/api/groups") def group(): data = body() diff --git a/tests/test_app.py b/tests/test_app.py index 947fba3..20eed4b 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -16,6 +16,9 @@ class FakeZone: def __init__(self, uid="RINCON_1", name="Wohnzimmer"): self.uid, self.player_name, self.ip_address = uid, name, "192.168.1.20" self._volume = 23 + self._bass = 1 + self._treble = -2 + self._loudness = True self.group = SimpleNamespace(coordinator=self, members=[self]) self.music_library = MagicMock() for method in ("play", "pause", "stop", "join", "unjoin", "play_uri", "play_from_queue"): @@ -33,6 +36,30 @@ class FakeZone: def volume(self, value): self._volume = value + @property + def bass(self): + return self._bass + + @bass.setter + def bass(self, value): + self._bass = value + + @property + def treble(self): + return self._treble + + @treble.setter + def treble(self, value): + self._treble = value + + @property + def loudness(self): + return self._loudness + + @loudness.setter + def loudness(self, value): + self._loudness = value + @pytest.fixture def zone(): @@ -52,6 +79,9 @@ def test_discovery_and_status(service): assert result[0]["name"] == "Wohnzimmer" assert result[0]["state"] == "PLAYING" assert result[0]["track"]["title"] == "Titel" + assert result[0]["bass"] == 1 + assert result[0]["treble"] == -2 + assert result[0]["loudness"] is True def test_transport_and_volume(service, zone): @@ -63,6 +93,19 @@ def test_transport_and_volume(service, zone): service.set_volume(zone.uid, 101) +def test_equalizer_controls(service, zone): + service.set_eq(zone.uid, "bass", -6) + service.set_eq(zone.uid, "treble", 7) + service.set_eq(zone.uid, "loudness", False) + assert (zone.bass, zone.treble, zone.loudness) == (-6, 7, False) + with pytest.raises(ValueError): + service.set_eq(zone.uid, "bass", 11) + with pytest.raises(ValueError): + service.set_eq(zone.uid, "loudness", 1) + with pytest.raises(ValueError): + service.set_eq(zone.uid, "balance", 0) + + def test_group_and_ungroup(monkeypatch): coordinator, member = FakeZone("A", "Küche"), FakeZone("B", "Bad") monkeypatch.setattr("app.sonos.soco.discover", MagicMock(return_value={coordinator, member})) @@ -146,6 +189,24 @@ def test_api_validation_and_error_mapping(service, zone): response = client.put(f"/api/zones/{zone.uid}/volume", json={"volume": "laut"}) assert response.status_code == 400 and response.json["error"] == "validation_error" assert client.post(f"/api/zones/{zone.uid}/transport", data="{}", content_type="text/plain").status_code == 400 + assert client.put(f"/api/zones/{zone.uid}/eq", json={"control":"bass", "value":4}).status_code == 200 + assert zone.bass == 4 + assert client.put(f"/api/zones/{zone.uid}/eq", json={"control":"treble", "value":99}).status_code == 400 + + +@pytest.mark.parametrize(("payload", "status"), [ + ({"control": "bass"}, 400), + ({"control": "bass", "value": None}, 400), + ({"control": "bass", "value": True}, 400), + ({"control": "bass", "value": 1.5}, 400), + ({"control": "bass", "value": -10}, 200), + ({"control": "treble", "value": 10}, 200), + ({"control": "loudness"}, 400), + ({"control": "loudness", "value": None}, 400), +]) +def test_equalizer_api_value_validation(service, zone, payload, status): + client = create_app({"TESTING": True}, service).test_client() + assert client.put(f"/api/zones/{zone.uid}/eq", json=payload).status_code == status def test_api_never_discovers_real_devices(monkeypatch, zone): @@ -176,3 +237,12 @@ def test_frontend_guards_stale_refreshes_and_preserves_members(): assert "zoneController.abort()" in source assert "request !== zoneRequest" in source assert "selectedMembers.has(zone.uid)" in source + + +def test_frontend_serializes_eq_writes_and_reloads_confirmed_values(): + source = Path("app/static/app.js").read_text(encoding="utf-8") + assert "if (pendingEq.has(key)) return" in source + assert "pendingEq.add(key); input.disabled = true" in source + assert source.count("await load(false)") >= 3 + assert "loudness.dataset.value !== 'true'" in source + assert "value:!zone.loudness" not in source -- 2.54.0