feat: add bass, treble and loudness controls #4

Merged
tom merged 1 commits from feat/equalizer-controls into main 2026-09-11 21:59:36 +00:00
5 changed files with 140 additions and 2 deletions

No files matched your search

+16 -1
View File
@@ -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.")
+30 -1
View File
@@ -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();
}
+18
View File
@@ -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;
+6
View File
@@ -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/<uid>/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()
+70
View File
@@ -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