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"