Skip to content

Commit 58ea613

Browse files
committed
Support event subscriptions
1 parent fdc0d16 commit 58ea613

3 files changed

Lines changed: 172 additions & 2 deletions

File tree

camilladsp/camillaws.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
This module contains the websocket connection class.
55
"""
66

7-
from typing import Dict, Tuple, Optional, Union
7+
from typing import Any, Callable, Dict, Tuple, Optional, Union
88
from threading import Lock
99
import json
1010
from websocket import create_connection, WebSocket # type: ignore
@@ -79,6 +79,21 @@ def _handle_reply(self, command: str, rawreply: Union[str, bytes]):
7979
except json.JSONDecodeError as err:
8080
raise IOError(f"Invalid response received: {rawreply!r}") from err
8181

82+
def _handle_event_reply(self, event_name: str, rawreply: Union[str, bytes]):
83+
try:
84+
reply = json.loads(rawreply)
85+
if event_name not in reply:
86+
return None
87+
response_data = reply[event_name]
88+
result = response_data["result"]
89+
state, message = self._handle_result(result)
90+
value = response_data.get("value")
91+
if state == "Ok":
92+
return value
93+
_raise_error(state, message, value)
94+
except json.JSONDecodeError as err:
95+
raise IOError(f"Invalid response received: {rawreply!r}") from err
96+
8297
def _handle_result(
8398
self, result: Union[str, Dict[str, str]]
8499
) -> Tuple[str, Optional[str]]:
@@ -105,6 +120,73 @@ def connect(self):
105120
self._ws = None
106121
raise
107122

123+
def subscribe_events(
124+
self,
125+
command: str,
126+
event_name: str,
127+
callback: Callable[[Any], Optional[bool]],
128+
arg=None,
129+
):
130+
"""
131+
Start a subscription and call `callback` for each incoming event value.
132+
133+
This method blocks until the callback returns `False`, or an exception
134+
is raised. A `StopSubscription` command is sent before returning.
135+
136+
Args:
137+
command (str): Subscription command to send.
138+
event_name (str): Name of event messages to listen for.
139+
callback: Function called with each event payload.
140+
arg: Optional parameter to send with the subscription command.
141+
"""
142+
if not callable(callback):
143+
raise TypeError("callback must be callable")
144+
if self._ws is None:
145+
raise IOError("Not connected to CamillaDSP")
146+
147+
subscribed = False
148+
try:
149+
with self._lock:
150+
if arg is not None:
151+
query = json.dumps({command: arg})
152+
else:
153+
query = json.dumps(command)
154+
self._ws.send(query)
155+
rawrepl = self._ws.recv()
156+
except Exception as err:
157+
self._ws = None
158+
raise IOError("Lost connection to CamillaDSP") from err
159+
160+
self._handle_reply(command, rawrepl)
161+
subscribed = True
162+
163+
try:
164+
while True:
165+
if self._ws is None:
166+
raise IOError("Lost connection to CamillaDSP")
167+
try:
168+
with self._lock:
169+
raw_event = self._ws.recv()
170+
except Exception as err:
171+
self._ws = None
172+
raise IOError("Lost connection to CamillaDSP") from err
173+
174+
event_data = self._handle_event_reply(event_name, raw_event)
175+
if event_data is None:
176+
continue
177+
should_continue = callback(event_data)
178+
if should_continue is False:
179+
break
180+
finally:
181+
if subscribed and self._ws is not None:
182+
try:
183+
with self._lock:
184+
self._ws.send(json.dumps("StopSubscription"))
185+
rawrepl = self._ws.recv()
186+
self._handle_reply("StopSubscription", rawrepl)
187+
except Exception:
188+
self._ws = None
189+
108190
def disconnect(self):
109191
"""
110192
Close the connection to the websocket.

camilladsp/levels.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
This module contains commands for reading levels.
55
"""
66

7-
from typing import Dict, List
7+
from typing import Any, Callable, Dict, List, Optional
88
import math
99

1010
from .commandgroup import _CommandGroup
@@ -201,3 +201,29 @@ def labels(self):
201201
"""
202202
labels = self.client.query("GetChannelLabels")
203203
return labels
204+
205+
def subscribe_signal_levels(
206+
self,
207+
callback: Callable[[Dict[str, Any]], Optional[bool]],
208+
side: str = "both",
209+
):
210+
"""
211+
Subscribe to signal level events and call `callback` for each event.
212+
213+
This method blocks until `callback` returns `False`.
214+
215+
Args:
216+
callback: Function that receives event payloads.
217+
Typical payload keys are `side`, `rms`, and `peak`.
218+
side (str): Which side to subscribe to. One of
219+
`capture`, `playback`, or `both`.
220+
"""
221+
if side not in ("capture", "playback", "both"):
222+
raise ValueError("side must be one of: capture, playback, both")
223+
224+
self.client.subscribe_events(
225+
command="SubscribeSignalLevels",
226+
arg=side,
227+
event_name="SignalLevelsEvent",
228+
callback=callback,
229+
)

tests/test_camillaws.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,3 +408,65 @@ def test_queries_customreplies(camilla_mockquery):
408408
camilla_mockquery.query.assert_called_with(
409409
"AdjustFaderVolume", arg=(0, (-5.0, -150.0, 3.0))
410410
)
411+
412+
413+
def test_subscribe_signal_levels(camilla_mockquery):
414+
callback = MagicMock(return_value=False)
415+
camilla_mockquery.subscribe_events = MagicMock()
416+
417+
camilla_mockquery.levels.subscribe_signal_levels(callback, side="playback")
418+
419+
camilla_mockquery.subscribe_events.assert_called_with(
420+
command="SubscribeSignalLevels",
421+
arg="playback",
422+
event_name="SignalLevelsEvent",
423+
callback=callback,
424+
)
425+
426+
427+
def test_subscribe_events(camilla_mockws):
428+
camilla_mockws.connect()
429+
sent = []
430+
replies = iter(
431+
[
432+
json.dumps({"SubscribeSignalLevels": {"result": "Ok"}}),
433+
json.dumps(
434+
{
435+
"SignalLevelsEvent": {
436+
"result": "Ok",
437+
"value": {
438+
"side": "capture",
439+
"rms": [-58.1, -57.6],
440+
"peak": [-39.4, -38.9],
441+
},
442+
}
443+
}
444+
),
445+
json.dumps({"StopSubscription": {"result": "Ok"}}),
446+
]
447+
)
448+
449+
camilla_mockws.mockconnection.send = MagicMock(side_effect=lambda msg: sent.append(msg))
450+
camilla_mockws.mockconnection.recv = MagicMock(side_effect=lambda: next(replies))
451+
452+
events = []
453+
454+
def on_event(event_data):
455+
events.append(event_data)
456+
return False
457+
458+
camilla_mockws.subscribe_events(
459+
command="SubscribeSignalLevels",
460+
arg="capture",
461+
event_name="SignalLevelsEvent",
462+
callback=on_event,
463+
)
464+
465+
assert sent == [json.dumps({"SubscribeSignalLevels": "capture"}), '"StopSubscription"']
466+
assert events == [
467+
{
468+
"side": "capture",
469+
"rms": [-58.1, -57.6],
470+
"peak": [-39.4, -38.9],
471+
}
472+
]

0 commit comments

Comments
 (0)