* feat(suspend): add D-Bus based suspend/resume handler Monitors org.freedesktop.login1.Manager.PrepareForSleep via Gio.DBusProxy to detect system suspend/resume. On resume, invokes a callback to reinitialize the audio pipeline and speech model so dictation works immediately without manual intervention. Fixes #367 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * feat(suspend): wire suspend handler into tray indicator and recognition manager TrayIndicator now creates a SuspendHandler on startup that listens for D-Bus PrepareForSleep signals. On suspend, active recognition is stopped. On resume, a 2-second delay allows audio hardware to stabilize before reinitializing the speech engine and resetting audio reconnection state. Fixes #367 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> --------- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
127 lines
4.0 KiB
Python
127 lines
4.0 KiB
Python
"""
|
|
Suspend/resume handler for Vocalinux.
|
|
|
|
Monitors systemd-logind via D-Bus for PrepareForSleep signals and
|
|
automatically reinitializes the audio pipeline and speech model
|
|
after the system resumes from suspend or hibernation.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Callable, Optional
|
|
|
|
from gi.repository import Gio, GLib
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_LOGIND_BUS_NAME = "org.freedesktop.login1"
|
|
_LOGIND_OBJECT_PATH = "/org/freedesktop/login1"
|
|
_LOGIND_INTERFACE = "org.freedesktop.login1.Manager"
|
|
_PREPARE_FOR_SLEEP_SIGNAL = "PrepareForSleep"
|
|
|
|
|
|
class SuspendHandler:
|
|
"""Handles system suspend/resume events via D-Bus.
|
|
|
|
Subscribes to org.freedesktop.login1.Manager.PrepareForSleep and
|
|
invokes callbacks when the system is about to suspend or has just
|
|
resumed. All callbacks run on the GLib main loop thread so GTK
|
|
operations are safe.
|
|
|
|
Usage::
|
|
|
|
handler = SuspendHandler(
|
|
on_suspend=my_stop_fn,
|
|
on_resume=my_resume_fn,
|
|
)
|
|
# ... later, on shutdown:
|
|
handler.shutdown()
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
on_suspend: Optional[Callable[[], None]] = None,
|
|
on_resume: Optional[Callable[[], None]] = None,
|
|
):
|
|
self._on_suspend = on_suspend
|
|
self._on_resume = on_resume
|
|
self._proxy: Optional[Gio.DBusProxy] = None
|
|
self._was_suspended = False
|
|
self._connect()
|
|
|
|
def _connect(self) -> None:
|
|
"""Create a D-Bus proxy and subscribe to PrepareForSleep."""
|
|
try:
|
|
self._proxy = Gio.DBusProxy.new_for_bus_sync(
|
|
bus_type=Gio.BusType.SYSTEM,
|
|
flags=Gio.DBusProxyFlags.NONE,
|
|
info=None,
|
|
name=_LOGIND_BUS_NAME,
|
|
object_path=_LOGIND_OBJECT_PATH,
|
|
interface_name=_LOGIND_INTERFACE,
|
|
cancellable=None,
|
|
)
|
|
self._proxy.connect("g-signal", self._on_signal)
|
|
logger.info("Suspend/resume handler initialized (logind D-Bus)")
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Unexpected error connecting to logind D-Bus: %s",
|
|
exc,
|
|
)
|
|
self._proxy = None
|
|
|
|
def _on_signal(
|
|
self,
|
|
proxy: Gio.DBusProxy,
|
|
sender_name: str,
|
|
signal_name: str,
|
|
parameters: GLib.Variant,
|
|
) -> None:
|
|
"""Handle incoming D-Bus signals from logind."""
|
|
if signal_name != _PREPARE_FOR_SLEEP_SIGNAL:
|
|
return
|
|
|
|
# The parameter is a tuple (b entering_sleep,)
|
|
if parameters.get_type_string() != "(b)":
|
|
logger.warning(
|
|
"Unexpected PrepareForSleep parameter type: %s",
|
|
parameters.get_type_string(),
|
|
)
|
|
return
|
|
|
|
entering_sleep = parameters.unpack()[0]
|
|
|
|
if entering_sleep:
|
|
logger.info("System preparing to suspend")
|
|
self._was_suspended = True
|
|
if self._on_suspend:
|
|
try:
|
|
self._on_suspend()
|
|
except Exception:
|
|
logger.error("Error in suspend callback", exc_info=True)
|
|
else:
|
|
logger.info("System resuming from suspend")
|
|
# Also handles the edge-case where we missed the
|
|
# PrepareForSleep(true) (e.g. hibernation resume —
|
|
# see systemd issue #30666).
|
|
self._was_suspended = False
|
|
if self._on_resume:
|
|
try:
|
|
self._on_resume()
|
|
except Exception:
|
|
logger.error("Error in resume callback", exc_info=True)
|
|
|
|
def shutdown(self) -> None:
|
|
"""Disconnect from D-Bus. Call from GTK destroy / quit handler."""
|
|
if self._proxy is not None:
|
|
try:
|
|
self._proxy.disconnect_by_func(self._on_signal)
|
|
except Exception:
|
|
pass
|
|
self._proxy = None
|
|
logger.info("Suspend/resume handler shut down")
|
|
|
|
@property
|
|
def active(self) -> bool:
|
|
"""Return True if the D-Bus proxy is connected."""
|
|
return self._proxy is not None
|