Merge feature/21 into integration/spec16 (ticket #21: D-Bus state signal)
This commit is contained in:
@@ -15,9 +15,34 @@ from typing import Callable, Optional
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
from .app_identity import BUS_NAME, INTERFACE_NAME, OBJECT_PATH
|
||||
from .common_types import RecognitionState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Dictation states exposed on the bus. ``ERROR`` is not dictation activity:
|
||||
#: the engine is idle again once it has failed, so it maps to ``idle``.
|
||||
DICTATION_STATE_IDLE = "idle"
|
||||
DICTATION_STATE_LISTENING = "listening"
|
||||
DICTATION_STATE_PROCESSING = "processing"
|
||||
DICTATION_STATES: tuple[str, ...] = (
|
||||
DICTATION_STATE_IDLE,
|
||||
DICTATION_STATE_LISTENING,
|
||||
DICTATION_STATE_PROCESSING,
|
||||
)
|
||||
STATE_CHANGED_SIGNAL = "StateChanged"
|
||||
STATE_PROPERTY = "State"
|
||||
PROPERTIES_INTERFACE = "org.freedesktop.DBus.Properties"
|
||||
|
||||
|
||||
def state_from_recognition(state: RecognitionState) -> str:
|
||||
"""Map an engine state to the bus-visible dictation state."""
|
||||
if state == RecognitionState.LISTENING:
|
||||
return DICTATION_STATE_LISTENING
|
||||
if state == RecognitionState.PROCESSING:
|
||||
return DICTATION_STATE_PROCESSING
|
||||
return DICTATION_STATE_IDLE
|
||||
|
||||
|
||||
# Maps CLI trigger names to the D-Bus method names exposed below.
|
||||
COMMAND_METHODS = {
|
||||
"toggle": "Toggle",
|
||||
@@ -31,6 +56,10 @@ _INTROSPECTION_XML = f"""
|
||||
<method name="Toggle"/>
|
||||
<method name="Start"/>
|
||||
<method name="Stop"/>
|
||||
<signal name="{STATE_CHANGED_SIGNAL}">
|
||||
<arg name="state" type="s" direction="out"/>
|
||||
</signal>
|
||||
<property name="{STATE_PROPERTY}" type="s" access="read"/>
|
||||
</interface>
|
||||
</node>
|
||||
"""
|
||||
@@ -70,9 +99,37 @@ class VocalinuxDBusService:
|
||||
self._owner_id = 0
|
||||
self._registration_id = 0
|
||||
self._connection: Optional[Gio.DBusConnection] = None
|
||||
self._dictation_state = DICTATION_STATE_IDLE
|
||||
self._node_info = Gio.DBusNodeInfo.new_for_xml(_INTROSPECTION_XML)
|
||||
self._register()
|
||||
|
||||
def get_state(self) -> str:
|
||||
"""Return the current bus-visible dictation state (``idle`` at start)."""
|
||||
return self._dictation_state
|
||||
|
||||
def set_state(self, state: str) -> bool:
|
||||
"""Publish a dictation state, emitting the signal only on change.
|
||||
|
||||
Unknown values are ignored. Returns True when subscribers were
|
||||
notified, False when the state was unchanged (or unknown), which is
|
||||
what keeps double start/stop from emitting duplicate transitions.
|
||||
"""
|
||||
if state not in DICTATION_STATES or state == self._dictation_state:
|
||||
return False
|
||||
self._dictation_state = state
|
||||
if self._connection is not None:
|
||||
try:
|
||||
self._connection.emit_signal(
|
||||
None,
|
||||
OBJECT_PATH,
|
||||
INTERFACE_NAME,
|
||||
STATE_CHANGED_SIGNAL,
|
||||
GLib.Variant("(s)", (state,)),
|
||||
)
|
||||
except GLib.Error:
|
||||
logger.debug("Failed to emit dictation state signal", exc_info=True)
|
||||
return True
|
||||
|
||||
def _register(self) -> None:
|
||||
"""Request ownership of the bus name on the session bus."""
|
||||
try:
|
||||
@@ -131,6 +188,9 @@ class VocalinuxDBusService:
|
||||
invocation: Gio.DBusMethodInvocation,
|
||||
) -> None:
|
||||
"""Dispatch an incoming method call to the matching callback."""
|
||||
if interface_name == PROPERTIES_INTERFACE:
|
||||
self._handle_properties_call(method_name, parameters, invocation)
|
||||
return
|
||||
callback = self._callbacks.get(method_name)
|
||||
if callback is None:
|
||||
invocation.return_error_literal(
|
||||
@@ -145,6 +205,33 @@ class VocalinuxDBusService:
|
||||
GLib.idle_add(self._invoke, callback)
|
||||
invocation.return_value(None)
|
||||
|
||||
def _handle_properties_call(
|
||||
self,
|
||||
method_name: str,
|
||||
parameters: GLib.Variant,
|
||||
invocation: Gio.DBusMethodInvocation,
|
||||
) -> None:
|
||||
"""Serve reads of the ``State`` property."""
|
||||
if method_name == "Get":
|
||||
interface, name = parameters.unpack()
|
||||
if interface == INTERFACE_NAME and name == STATE_PROPERTY:
|
||||
invocation.return_value(
|
||||
GLib.Variant("(v)", (GLib.Variant("s", self._dictation_state),))
|
||||
)
|
||||
return
|
||||
elif method_name == "GetAll":
|
||||
(interface,) = parameters.unpack()
|
||||
if interface == INTERFACE_NAME:
|
||||
invocation.return_value(
|
||||
GLib.Variant("(a{sv})", ({"State": GLib.Variant("s", self._dictation_state)},))
|
||||
)
|
||||
return
|
||||
invocation.return_error_literal(
|
||||
Gio.dbus_error_quark(),
|
||||
Gio.DBusError.UNKNOWN_PROPERTY,
|
||||
f"Unknown property call: {method_name}",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _invoke(callback: Callable[[], None]) -> bool:
|
||||
try:
|
||||
|
||||
@@ -921,6 +921,18 @@ class TrayIndicator:
|
||||
else:
|
||||
keepalive.cancel()
|
||||
|
||||
# Publish dictation state on D-Bus for status bars and scripts
|
||||
# (ticket #21). The service may not exist yet when the first state
|
||||
# callback fires during __init__, hence the guarded lookup.
|
||||
from ..dbus_service import state_from_recognition
|
||||
|
||||
dbus_service = getattr(self, "_dbus_service", None)
|
||||
if dbus_service is not None:
|
||||
try:
|
||||
dbus_service.set_state(state_from_recognition(state))
|
||||
except Exception:
|
||||
logger.debug("Failed to publish dictation state", exc_info=True)
|
||||
|
||||
# Update the UI in the GTK main thread
|
||||
GLib.idle_add(self._update_ui, state)
|
||||
|
||||
|
||||
@@ -314,3 +314,145 @@ def test_shutdown_swallows_unregister_and_unown_errors():
|
||||
|
||||
assert service._registration_id == 0
|
||||
assert service._owner_id == 0
|
||||
|
||||
|
||||
# -- Dictation state signal & property (ticket #21) -------------------------
|
||||
|
||||
|
||||
def _connected_service():
|
||||
"""A service with a mocked bus connection for emission assertions."""
|
||||
from vocalinux import dbus_service
|
||||
|
||||
service = _make_service()
|
||||
service._connection = MagicMock()
|
||||
return dbus_service, service
|
||||
|
||||
|
||||
def test_state_starts_idle():
|
||||
"""Reading the state property right after start returns idle."""
|
||||
_dbus_service, service = _connected_service()
|
||||
assert service.get_state() == "idle"
|
||||
|
||||
|
||||
def test_full_dictation_cycle_emits_each_transition():
|
||||
"""idle -> listening -> processing -> idle emits one signal per change."""
|
||||
dbus_service, service = _connected_service()
|
||||
|
||||
assert service.set_state("listening") is True
|
||||
assert service.set_state("processing") is True
|
||||
assert service.set_state("idle") is True
|
||||
|
||||
emitted = [call.args[3] for call in service._connection.emit_signal.call_args_list]
|
||||
assert emitted == ["StateChanged", "StateChanged", "StateChanged"]
|
||||
assert service.get_state() == "idle"
|
||||
|
||||
|
||||
def test_repeat_state_does_not_emit_duplicate():
|
||||
"""Starting twice or stopping while idle emits nothing further."""
|
||||
_dbus_service, service = _connected_service()
|
||||
|
||||
assert service.set_state("listening") is True
|
||||
assert service.set_state("listening") is False
|
||||
assert service.set_state("idle") is True
|
||||
assert service.set_state("idle") is False
|
||||
|
||||
assert service._connection.emit_signal.call_count == 2
|
||||
assert service.get_state() == "idle"
|
||||
|
||||
|
||||
def test_unknown_state_is_ignored():
|
||||
"""An unknown state never changes the property nor emits."""
|
||||
_dbus_service, service = _connected_service()
|
||||
|
||||
assert service.set_state("bogus") is False
|
||||
|
||||
assert service.get_state() == "idle"
|
||||
service._connection.emit_signal.assert_not_called()
|
||||
|
||||
|
||||
def test_state_from_recognition_maps_all_states():
|
||||
"""Engine states map to bus states; error reads back as idle."""
|
||||
from vocalinux import dbus_service
|
||||
from vocalinux.common_types import RecognitionState
|
||||
|
||||
assert dbus_service.state_from_recognition(RecognitionState.IDLE) == "idle"
|
||||
assert dbus_service.state_from_recognition(RecognitionState.LISTENING) == "listening"
|
||||
assert dbus_service.state_from_recognition(RecognitionState.PROCESSING) == "processing"
|
||||
assert dbus_service.state_from_recognition(RecognitionState.ERROR) == "idle"
|
||||
|
||||
|
||||
def test_properties_get_returns_current_state():
|
||||
"""A Properties.Get call for State returns the live value."""
|
||||
dbus_service, service = _connected_service()
|
||||
service.set_state("listening")
|
||||
|
||||
invocation = MagicMock()
|
||||
parameters = MagicMock()
|
||||
parameters.unpack.return_value = (dbus_service.INTERFACE_NAME, "State")
|
||||
service._handle_method_call(
|
||||
MagicMock(),
|
||||
"sender",
|
||||
dbus_service.OBJECT_PATH,
|
||||
dbus_service.PROPERTIES_INTERFACE,
|
||||
"Get",
|
||||
parameters,
|
||||
invocation,
|
||||
)
|
||||
|
||||
invocation.return_value.assert_called_once()
|
||||
assert service.get_state() == "listening"
|
||||
|
||||
|
||||
def test_properties_get_unknown_returns_error():
|
||||
"""A Properties.Get call for another interface errors without emitting."""
|
||||
dbus_service, service = _connected_service()
|
||||
|
||||
invocation = MagicMock()
|
||||
parameters = MagicMock()
|
||||
parameters.unpack.return_value = ("org.example.Other", "State")
|
||||
service._handle_method_call(
|
||||
MagicMock(),
|
||||
"sender",
|
||||
dbus_service.OBJECT_PATH,
|
||||
dbus_service.PROPERTIES_INTERFACE,
|
||||
"Get",
|
||||
parameters,
|
||||
invocation,
|
||||
)
|
||||
|
||||
invocation.return_error_literal.assert_called_once()
|
||||
service._connection.emit_signal.assert_not_called()
|
||||
|
||||
|
||||
def test_control_methods_still_dispatch_after_state_changes():
|
||||
"""Toggle/Start/Stop keep working exactly as before (ticket #21)."""
|
||||
from vocalinux import dbus_service
|
||||
|
||||
callbacks = {
|
||||
"on_toggle": MagicMock(),
|
||||
"on_start": MagicMock(),
|
||||
"on_stop": MagicMock(),
|
||||
}
|
||||
service = dbus_service.VocalinuxDBusService(
|
||||
on_toggle=callbacks["on_toggle"],
|
||||
on_start=callbacks["on_start"],
|
||||
on_stop=callbacks["on_stop"],
|
||||
)
|
||||
service._connection = MagicMock()
|
||||
service.set_state("processing")
|
||||
|
||||
invocation = MagicMock()
|
||||
with patch.object(dbus_service.GLib, "idle_add", side_effect=lambda fn, *a: fn(*a)):
|
||||
service._handle_method_call(
|
||||
MagicMock(),
|
||||
"sender",
|
||||
dbus_service.OBJECT_PATH,
|
||||
dbus_service.INTERFACE_NAME,
|
||||
"Stop",
|
||||
None,
|
||||
invocation,
|
||||
)
|
||||
|
||||
callbacks["on_stop"].assert_called_once_with()
|
||||
invocation.return_value.assert_called_once()
|
||||
assert service.get_state() == "processing"
|
||||
|
||||
Reference in New Issue
Block a user