[verified] feat: add equalizer controls
This commit is contained in:
1 parent
2e84adb338
commit
8b9633e857
5 files changed
+140
-2
No files matched your search
+16
-1
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user