diff --git a/src/vocalinux/dbus_service.py b/src/vocalinux/dbus_service.py index faa3e80..0ca66fe 100644 --- a/src/vocalinux/dbus_service.py +++ b/src/vocalinux/dbus_service.py @@ -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""" + + + + """ @@ -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: diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index 134acda..eb76cbb 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -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) diff --git a/tests/test_dbus_activation.py b/tests/test_dbus_activation.py index 59b0f65..3f333fa 100644 --- a/tests/test_dbus_activation.py +++ b/tests/test_dbus_activation.py @@ -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"