diff --git a/README.md b/README.md index 4896ce9..922409a 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,12 @@ -# Python-With-Gtk-Template -A template project for Python with Gtk applications. +# Pulstar +Pulstar is a Python + Gtk app to control the volume levels. -### Requirements +# Requirements * PyGObject * setproctitle -* pyxdg -### Note -There are a "\" strings and files that need to be set according to your app's name located at: -* \_\_builtins\_\_.py -* user_config/bin/app_name -* user_config/usr/share/app_name -* user_config/usr/share/app_name/icons/app_name.png -* user_config/usr/share/app_name/icons/app_name-64x64.png -* user_config/usr/share/applications/app_name.desktop +# Notes +Still Work in progress! Use at own risk! - -For the user_config, after changing names and files, copy all content to their respective destinations. -The logic follows Debian Dpkg packaging and its placement logic. +# Images +![1 Pulstar with sound sinks. ](images/pic1.png) diff --git a/images/pic1.png b/images/pic1.png new file mode 100644 index 0000000..2dbc23f Binary files /dev/null and b/images/pic1.png differ diff --git a/plugins/README.txt b/plugins/README.txt deleted file mode 100644 index 4173ddd..0000000 --- a/plugins/README.txt +++ /dev/null @@ -1,2 +0,0 @@ -### Note -Copy the example and rename it to your desired name. The Main class and passed in arguments are required. You don't necessarily need to use the passed in socket_id or event_system. diff --git a/plugins/template/__init__.py b/plugins/template/__init__.py deleted file mode 100644 index d36fa8c..0000000 --- a/plugins/template/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" - Pligin Module -""" diff --git a/plugins/template/__main__.py b/plugins/template/__main__.py deleted file mode 100644 index a576329..0000000 --- a/plugins/template/__main__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" - Pligin Package -""" diff --git a/plugins/template/manifest.json b/plugins/template/manifest.json deleted file mode 100644 index 4dcbf47..0000000 --- a/plugins/template/manifest.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "manifest": { - "name": "Example Plugin", - "author": "John Doe", - "version": "0.0.1", - "support": "", - "requests": { - "ui_target": "plugin_control_list", - "pass_fm_events": "true", - "bind_keys": ["Example Plugin||send_message:f"] - } - } -} diff --git a/plugins/template/plugin.py b/plugins/template/plugin.py deleted file mode 100644 index c52c0ff..0000000 --- a/plugins/template/plugin.py +++ /dev/null @@ -1,51 +0,0 @@ -# Python imports -import os -import threading -import subprocess -import time - -# Lib imports -import gi -gi.require_version('Gtk', '3.0') -from gi.repository import Gtk - -# Application imports -from plugins.plugin_base import PluginBase - - - - -# NOTE: Threads WILL NOT die with parent's destruction. -def threaded(fn): - def wrapper(*args, **kwargs): - threading.Thread(target=fn, args=args, kwargs=kwargs, daemon=False).start() - return wrapper - -# NOTE: Threads WILL die with parent's destruction. -def daemon_threaded(fn): - def wrapper(*args, **kwargs): - threading.Thread(target=fn, args=args, kwargs=kwargs, daemon=True).start() - return wrapper - - - - -class Plugin(PluginBase): - def __init__(self): - super().__init__() - - self.name = "Example Plugin" # NOTE: Need to remove after establishing private bidirectional 1-1 message bus - # where self.name should not be needed for message comms - - - def generate_reference_ui_element(self): - button = Gtk.Button(label=self.name) - button.connect("button-release-event", self.send_message) - return button - - def run(self): - ... - - def send_message(self, widget=None, eve=None): - message = "Hello, World!" - event_system.emit("display_message", ("warning", message, None)) diff --git a/src/__builtins__.py b/src/__builtins__.py index c7b3d32..e2c02ca 100644 --- a/src/__builtins__.py +++ b/src/__builtins__.py @@ -29,7 +29,7 @@ def daemon_threaded_wrapper(fn): # NOTE: Just reminding myself we can add to builtins two different ways... # __builtins__.update({"event_system": Builtins()}) -builtins.app_name = "" +builtins.app_name = "Pulstar" builtins.keybindings = Keybindings() builtins.event_system = EventSystem() builtins.endpoint_registry = EndpointRegistry() diff --git a/src/core/containers/base_container.py b/src/core/containers/base_container.py index 272f744..5268b9c 100644 --- a/src/core/containers/base_container.py +++ b/src/core/containers/base_container.py @@ -4,19 +4,28 @@ import gi gi.require_version('Gtk', '3.0') from gi.repository import Gtk +from gi.repository import GLib # Application imports - +from utils.pulsectl import pulsectl +from ..widgets.audio_sink import AudioSink class BaseContainer(Gtk.Box): def __init__(self): super(BaseContainer, self).__init__() - self._builder = settings.get_builder() + self._scroll = None + self._box = None + + self.pulse = None + self.pulse_events = None + self.sink_inputs = None + self.pause_sink = False self._setup_styling() self._setup_signals() + self._subscribe_to_events() self._load_widgets() self.show_all() @@ -24,20 +33,97 @@ class BaseContainer(Gtk.Box): def _setup_styling(self): self.set_orientation(Gtk.Orientation.VERTICAL) + self.set_margin_top(10) + self.set_margin_bottom(10) + self.set_margin_left(10) + self.set_margin_right(10) def _setup_signals(self): ... + def _subscribe_to_events(self): + event_system.subscribe("handle_new_sync_input", self._handle_new_sync_input) + event_system.subscribe("handle_del_sync_input", self._handle_del_sync_input) + event_system.subscribe("handle_cng_sync_input", self._handle_cng_sync_input) + def _load_widgets(self): - glade_box = self._builder.get_object("glade_box") - button = Gtk.Button(label="Click Me!") + scroll = Gtk.ScrolledWindow() + viewport = Gtk.Viewport() + box = Gtk.Box() + scroll.add(viewport) + viewport.add(box) - button.connect("clicked", self._hello_world) + try: + pulse = pulsectl.Pulse() + self.pulse_events = pulsectl.Pulse('event-printer') + si, sink_inputs, modules, clients, sink_list = pulse.server_info(), pulse.sink_input_list(), pulse.module_list(), pulse.client_list(), pulse.sink_list() - self.add(button) - self.add(glade_box) + logger.debug(f"\n\nServer Info\n{si}\n\nSink Inputs:") + for sink in sink_list: + self.add( AudioSink(pulse, sink) ) + self.pulse = pulse + self.sink_inputs = sink_inputs + for sink_input in sink_inputs: + box.add( AudioSink(pulse, sink_input) ) + except Exception as e: + logger.debug(f"{e}") + self._box = box + self._scroll = scroll.get_vadjustment() - def _hello_world(self, widget=None, eve=None): - logger.debug("Hello, World!") + self._scroll.connect("changed", self._scroll_to_bottom) + + scroll.set_vexpand(True) + scroll.set_overlay_scrolling(False) + scroll.set_margin_top(20) + scroll.set_margin_bottom(10) + box.set_orientation(Gtk.Orientation.VERTICAL) + self.add(scroll) + + self.set_pulse_event_listener() + + def _handle_new_sync_input(self, index): + sink_input = self.pulse.sink_input_list()[-1] + self._box.add( AudioSink(self.pulse, sink_input) ) + + def _handle_del_sync_input(self, index): + for sink_input in self.sink_inputs: + if index == sink_input.index: + self.sink_inputs.remove(sink_input) + + for child in self._box.get_children(): + if index == child.sink.index: + child.destroy() + + def _handle_cng_sync_input(self, index): + for child in self._box.get_children(): + if index == child.sink.index: + child.do_update() + + def _scroll_to_bottom(self, adjustment): + self._scroll.set_value( adjustment.get_upper() ) + + @daemon_threaded + def set_pulse_event_listener(self): + try: + self.pulse_events.event_mask_set('all') + self.pulse_events.event_callback_set(self._sub_threaded_event) + self.pulse_events.event_listen(timeout = 0) + except Exception as e: + logger.debug(f"{e}") + + def _sub_threaded_event(self, eve): + logger.debug(f"Pulse event: {eve}") + GLib.idle_add(self.handle_event, *(eve,)) + + def handle_event(self, eve): + if eve.facility == "sink_input": + if eve.t == "change": + event_system.emit("handle_cng_sync_input", (eve.index,)) + + if eve.t == "new": + event_system.emit("handle_new_sync_input", (eve.index,)) + + if eve.t == "remove": + event_system.emit("handle_del_sync_input", (eve.index,)) diff --git a/src/core/controller.py b/src/core/controller.py index 6d7c8c3..7909b69 100644 --- a/src/core/controller.py +++ b/src/core/controller.py @@ -4,20 +4,15 @@ import os # Lib imports import gi gi.require_version('Gtk', '3.0') -gi.require_version('Gdk', '3.0') from gi.repository import Gtk -from gi.repository import Gdk -from gi.repository import GLib # Application imports -from .mixins.signals_mixins import SignalsMixins -from .mixins.dummy_mixin import DummyMixin from .controller_data import ControllerData from .containers.base_container import BaseContainer -class Controller(DummyMixin, SignalsMixins, ControllerData): +class Controller(ControllerData): def __init__(self, args, unknownargs): self.setup_controller_data() @@ -25,44 +20,21 @@ class Controller(DummyMixin, SignalsMixins, ControllerData): self._setup_signals() self._subscribe_to_events() - self.print_hello_world() # A mixin method from the DummyMixin file - - if args.no_plugins == "false": - self.plugins.launch_plugins() - - for arg in unknownargs + [args.new_tab,]: - if os.path.isfile(arg): - message = f"FILE|{arg}" - event_system.emit("post_file_to_ipc", message) - - if os.path.isdir(arg): - message = f"DIR|{arg}" - event_system.emit("post_file_to_ipc", message) - - logger.info(f"Made it past {self.__class__} loading...") - def _setup_styling(self): ... def _setup_signals(self): - self.window.connect("focus-out-event", self.unset_keys_and_data) - self.window.connect("key-press-event", self.on_global_key_press_controller) - self.window.connect("key-release-event", self.on_global_key_release_controller) + ... def _subscribe_to_events(self): - event_system.subscribe("handle_file_from_ipc", self.handle_file_from_ipc) - event_system.subscribe("handle_dir_from_ipc", self.handle_dir_from_ipc) event_system.subscribe("tggl_top_main_menubar", self._tggl_top_main_menubar) def _tggl_top_main_menubar(self): logger.debug("_tggl_top_main_menubar > stub...") def setup_builder_and_container(self): - self.builder = Gtk.Builder() - self.builder.add_from_file(settings.get_glade_file()) - self.builder.expose_object("main_window", self.window) - + self.builder = Gtk.Builder() settings.set_builder(self.builder) self.base_container = BaseContainer() diff --git a/src/core/controller_data.py b/src/core/controller_data.py index 5e11284..52f01c5 100644 --- a/src/core/controller_data.py +++ b/src/core/controller_data.py @@ -5,7 +5,6 @@ import subprocess # Lib imports # Application imports -from plugins.plugins_controller import PluginsController @@ -22,7 +21,6 @@ class ControllerData: self.alt_down = False self.setup_builder_and_container() - self.plugins = PluginsController() def clear_console(self) -> None: diff --git a/src/core/mixins/__init__.py b/src/core/mixins/__init__.py deleted file mode 100644 index 4589fc7..0000000 --- a/src/core/mixins/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" - Generic Mixins Module -""" diff --git a/src/core/mixins/dummy_mixin.py b/src/core/mixins/dummy_mixin.py deleted file mode 100644 index ff09700..0000000 --- a/src/core/mixins/dummy_mixin.py +++ /dev/null @@ -1,13 +0,0 @@ -# Python imports - -# Lib imports - -# Application imports - - - -class DummyMixin: - """ DummyMixin is an example of how mixins are used and structured in a project. """ - - def print_hello_world(self) -> None: - logger.debug("Hello, World!") diff --git a/src/core/mixins/signals/__init__.py b/src/core/mixins/signals/__init__.py deleted file mode 100644 index 03c3ec2..0000000 --- a/src/core/mixins/signals/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" - Signals module -""" diff --git a/src/core/mixins/signals/ipc_signals_mixin.py b/src/core/mixins/signals/ipc_signals_mixin.py deleted file mode 100644 index bbabd1e..0000000 --- a/src/core/mixins/signals/ipc_signals_mixin.py +++ /dev/null @@ -1,20 +0,0 @@ -# Python imports - -# Lib imports - -# Application imports - - - - -class IPCSignalsMixin: - """ IPCSignalsMixin handle messages from another starting solarfm process. """ - - def print_to_console(self, message=None): - logger.debug(message) - - def handle_file_from_ipc(self, path: str) -> None: - logger.debug(f"File From IPC: {path}") - - def handle_dir_from_ipc(self, path: str) -> None: - logger.debug(f"Dir From IPC: {path}") \ No newline at end of file diff --git a/src/core/mixins/signals/keyboard_signals_mixin.py b/src/core/mixins/signals/keyboard_signals_mixin.py deleted file mode 100644 index 1a99277..0000000 --- a/src/core/mixins/signals/keyboard_signals_mixin.py +++ /dev/null @@ -1,94 +0,0 @@ -# Python imports -import re - -# Lib imports -import gi -gi.require_version('Gtk', '3.0') -gi.require_version('Gdk', '3.0') -from gi.repository import Gtk -from gi.repository import Gdk - -# Application imports - - - -valid_keyvalue_pat = re.compile(r"[a-z0-9A-Z-_\[\]\(\)\| ]") - - - -class KeyboardSignalsMixin: - """ KeyboardSignalsMixin keyboard hooks controller. """ - - # TODO: Need to set methods that use this to somehow check the keybindings state instead. - def unset_keys_and_data(self, widget=None, eve=None): - self.ctrl_down = False - self.shift_down = False - self.alt_down = False - - def on_global_key_press_controller(self, eve, user_data): - keyname = Gdk.keyval_name(user_data.keyval).lower() - modifiers = Gdk.ModifierType(user_data.get_state() & ~Gdk.ModifierType.LOCK_MASK) - - self.was_midified_key = True if modifiers != 0 else False - - if keyname.replace("_l", "").replace("_r", "") in ["control", "alt", "shift"]: - if "control" in keyname: - self.ctrl_down = True - if "shift" in keyname: - self.shift_down = True - if "alt" in keyname: - self.alt_down = True - - def on_global_key_release_controller(self, widget, event): - """ Handler for keyboard events """ - keyname = Gdk.keyval_name(event.keyval).lower() - modifiers = Gdk.ModifierType(event.get_state() & ~Gdk.ModifierType.LOCK_MASK) - - if keyname.replace("_l", "").replace("_r", "") in ["control", "alt", "shift"]: - should_return = self.was_midified_key and (self.ctrl_down or self.shift_down or self.alt_down) - - if "control" in keyname: - self.ctrl_down = False - if "shift" in keyname: - self.shift_down = False - if "alt" in keyname: - self.alt_down = False - - # NOTE: In effect a filter after releasing a modifier and we have a modifier mapped - if should_return: - self.was_midified_key = False - return - - mapping = keybindings.lookup(event) - logger.debug(f"on_global_key_release_controller > key > {keyname}") - logger.debug(f"on_global_key_release_controller > keyval > {event.keyval}") - logger.debug(f"on_global_key_release_controller > mapping > {mapping}") - - if mapping: - # See if in controller scope - try: - getattr(self, mapping)() - return True - except Exception: - # Must be plugins scope, event call, OR we forgot to add method to controller scope - if "||" in mapping: - sender, eve_type = mapping.split("||") - else: - sender = "" - eve_type = mapping - - self.handle_key_event_system(sender, eve_type) - else: - logger.debug(f"on_global_key_release_controller > key > {keyname}") - - if self.ctrl_down: - if not keyname in ["1", "kp_1", "2", "kp_2", "3", "kp_3", "4", "kp_4"]: - self.handle_key_event_system(None, mapping) - else: - ... - - def handle_key_event_system(self, sender, eve_type): - event_system.emit(eve_type) - - def keyboard_close_tab(self): - ... diff --git a/src/core/mixins/signals_mixins.py b/src/core/mixins/signals_mixins.py deleted file mode 100644 index 76515f6..0000000 --- a/src/core/mixins/signals_mixins.py +++ /dev/null @@ -1,13 +0,0 @@ -# Python imports - -# Lib imports -from .signals.ipc_signals_mixin import IPCSignalsMixin -from .signals.keyboard_signals_mixin import KeyboardSignalsMixin - -# Application imports - - - - -class SignalsMixins(KeyboardSignalsMixin, IPCSignalsMixin): - ... diff --git a/src/core/widgets/audio_sink.py b/src/core/widgets/audio_sink.py new file mode 100644 index 0000000..c71440f --- /dev/null +++ b/src/core/widgets/audio_sink.py @@ -0,0 +1,120 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports + + + +class AudioSink(Gtk.Box): + """docstring for AudioSink.""" + + def __init__(self, pulse, sink): + super(AudioSink, self).__init__() + + self._scale = None + self._mute_btn = None + self.pulse = pulse + self.sink = sink + self.min_range = 0 + self.max_range = 150 + self.muted = False + self.block_update = False + + self._setup_styling() + self._setup_signals() + self._load_widgets() + + self.show_all() + + + def _setup_styling(self): + self.set_orientation(Gtk.Orientation.VERTICAL) + self.set_margin_top(10) + self.set_margin_bottom(10) + + def _setup_signals(self): + ... + + def _load_widgets(self): + current_lvl = self.sink.volume.values[0] * 100 + box = Gtk.Box() + label = Gtk.Label() + scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, self.min_range, self.max_range, 1) + mute_btn = Gtk.Button() + self.muted = False if self.sink.mute == 0 else True + + # logger.debug(f"{self.sink.proplist}") + try: + name = self.sink.description + except Exception as e: + name = self.sink.proplist["application.name"] + + label.set_text(name) + label.set_xalign(0.0) + scale.add_mark(0.0, Gtk.PositionType.BOTTOM, "Silence") + scale.add_mark(50.0, Gtk.PositionType.BOTTOM, "50%") + scale.add_mark(100.0, Gtk.PositionType.BOTTOM, "100%") + scale.set_value(current_lvl) + + scale.set_hexpand(True) + box.set_orientation(Gtk.Orientation.HORIZONTAL) + mute_btn.set_margin_left(10) + self.set_mute_image(mute_btn) + + scale.connect("value-changed", self.set_volume) + mute_btn.connect("clicked", self.toggle_mute) + mute_btn.set_tooltip_text("Mute...") + + self._scale = scale + self._mute_btn = mute_btn + + self.add(label) + box.add(scale) + box.add(mute_btn) + self.add(box) + + + def set_volume(self, range): + if self.block_update: + return + + value = range.get_value() / 100 + sink_vol_info = self.sink.volume + new_level = [] + + for level in sink_vol_info.values: + new_level.append(value) + + sink_vol_info.values = new_level + self.pulse.sink_input_volume_set(self.sink.index, sink_vol_info) + + def toggle_mute(self, widget = None, eve = None): + self.muted = not self.muted + self.set_mute_image(widget) + self.pulse.sink_input_mute(self.sink.index, mute=self.muted) + + def set_mute_image(self, mute_btn): + if self.muted: + mute_btn.set_image( Gtk.Image.new_from_icon_name("gtk-disconnect", 3) ) + else: + mute_btn.set_image( Gtk.Image.new_from_icon_name("gtk-connect", 3) ) + + def do_update(self): + self.block_update = True + + sink_inputs = self.pulse.sink_input_list() + for sink_input in sink_inputs: + if sink_input.index == self.sink.index: + self.sink = sink_input + + sink_vol_info = self.sink.volume.values + self.muted = False if self.sink.mute == 0 else True + + self.set_mute_image(self._mute_btn) + self._scale.set_value( sink_vol_info[0] * 100 ) + + self.block_update = False diff --git a/src/plugins/__init__.py b/src/plugins/__init__.py deleted file mode 100644 index 5624b32..0000000 --- a/src/plugins/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" - Gtk Bound Plugins Module -""" diff --git a/src/plugins/manifest.py b/src/plugins/manifest.py deleted file mode 100644 index 4088eed..0000000 --- a/src/plugins/manifest.py +++ /dev/null @@ -1,64 +0,0 @@ -# Python imports -import os -import json -from os.path import join - -# Lib imports - -# Application imports - - - - -class ManifestProcessor(Exception): - ... - - -class Plugin: - path: str = None - name: str = None - author: str = None - version: str = None - support: str = None - requests:{} = None - reference: type = None - - -class ManifestProcessor: - def __init__(self, path, builder): - manifest = join(path, "manifest.json") - if not os.path.exists(manifest): - raise Exception("Invalid Plugin Structure: Plugin doesn't have 'manifest.json'. Aboarting load...") - - self._path = path - self._builder = builder - with open(manifest) as f: - data = json.load(f) - self._manifest = data["manifest"] - self._plugin = self.collect_info() - - def collect_info(self) -> Plugin: - plugin = Plugin() - plugin.path = self._path - plugin.name = self._manifest["name"] - plugin.author = self._manifest["author"] - plugin.version = self._manifest["version"] - plugin.support = self._manifest["support"] - plugin.requests = self._manifest["requests"] - - return plugin - - def get_loading_data(self): - loading_data = {} - requests = self._plugin.requests - keys = requests.keys() - - if "pass_events" in keys: - if requests["pass_events"] in ["true"]: - loading_data["pass_events"] = True - - if "bind_keys" in keys: - if isinstance(requests["bind_keys"], list): - loading_data["bind_keys"] = requests["bind_keys"] - - return self._plugin, loading_data diff --git a/src/plugins/plugin_base.py b/src/plugins/plugin_base.py deleted file mode 100644 index 3130bb4..0000000 --- a/src/plugins/plugin_base.py +++ /dev/null @@ -1,61 +0,0 @@ -# Python imports -import os -import time - -# Lib imports - -# Application imports - - -class PluginBaseException(Exception): - ... - - -class PluginBase: - def __init__(self): - self.name = "Example Plugin" # NOTE: Need to remove after establishing private bidirectional 1-1 message bus - # where self.name should not be needed for message comms - - self._builder = None - self._ui_objects = None - self._event_system = None - - - def run(self): - """ - Must define regardless if needed and can 'pass' if plugin doesn't need it. - Is intended to be used to setup internal signals or custom Gtk Builders/UI logic. - """ - raise PluginBaseException("Method hasn't been overriden...") - - def generate_reference_ui_element(self): - """ - Requests Key: 'ui_target': "plugin_control_list", - Must define regardless if needed and can 'pass' if plugin doesn't use it. - Must return a widget if "ui_target" is set. - """ - raise PluginBaseException("Method hasn't been overriden...") - - def set_event_system(self, event_system): - """ - Requests Key: 'pass_events': "true" - Must define in plugin if "pass_events" is set to "true" string. - """ - self._event_system = event_system - - def set_ui_object_collection(self, ui_objects): - """ - Requests Key: "pass_ui_objects": [""] - Request reference to a UI component. Will be passed back as array to plugin. - Must define in plugin if set and an array of valid glade UI IDs is given. - """ - self._ui_objects = ui_objects - - def subscribe_to_events(self): - ... - - - def clear_children(self, widget: type) -> None: - """ Clear children of a gtk widget. """ - for child in widget.get_children(): - widget.remove(child) diff --git a/src/plugins/plugins_controller.py b/src/plugins/plugins_controller.py deleted file mode 100644 index 8e1fad2..0000000 --- a/src/plugins/plugins_controller.py +++ /dev/null @@ -1,118 +0,0 @@ -# Python imports -import os -import sys -import importlib -import traceback -from os.path import join -from os.path import isdir - -# Lib imports -import gi -gi.require_version('Gtk', '3.0') -from gi.repository import Gtk -from gi.repository import Gio - -# Application imports -from .manifest import Plugin -from .manifest import ManifestProcessor - - - - -class InvalidPluginException(Exception): - ... - - -class PluginsController: - """PluginsController controller""" - - def __init__(self): - path = os.path.dirname(os.path.realpath(__file__)) - sys.path.insert(0, path) # NOTE: I think I'm not using this correctly... - - self._builder = settings.get_builder() - self._plugins_path = settings.get_plugins_path() - - self._plugins_dir_watcher = None - self._plugin_collection = [] - - - def launch_plugins(self) -> None: - self._set_plugins_watcher() - self.load_plugins() - - def _set_plugins_watcher(self) -> None: - self._plugins_dir_watcher = Gio.File.new_for_path(self._plugins_path) \ - .monitor_directory(Gio.FileMonitorFlags.WATCH_MOVES, Gio.Cancellable()) - self._plugins_dir_watcher.connect("changed", self._on_plugins_changed, ()) - - def _on_plugins_changed(self, file_monitor, file, other_file=None, eve_type=None, data=None): - if eve_type in [Gio.FileMonitorEvent.CREATED, Gio.FileMonitorEvent.DELETED, - Gio.FileMonitorEvent.RENAMED, Gio.FileMonitorEvent.MOVED_IN, - Gio.FileMonitorEvent.MOVED_OUT]: - self.reload_plugins(file) - - def load_plugins(self, file: str = None) -> None: - logger.debug(f"Loading plugins...") - parent_path = os.getcwd() - - for path, folder in [[join(self._plugins_path, item), item] if os.path.isdir(join(self._plugins_path, item)) else None for item in os.listdir(self._plugins_path)]: - try: - target = join(path, "plugin.py") - manifest = ManifestProcessor(path, self._builder) - - if not os.path.exists(target): - raise InvalidPluginException("Invalid Plugin Structure: Plugin doesn't have 'plugin.py'. Aboarting load...") - - plugin, loading_data = manifest.get_loading_data() - module = self.load_plugin_module(path, folder, target) - self.execute_plugin(module, plugin, loading_data) - except Exception as e: - logger.debug(f"Malformed Plugin: Not loading -->: '{folder}' !\n{traceback.print_exc()}") - - os.chdir(parent_path) - - - def load_plugin_module(self, path, folder, target): - os.chdir(path) - - locations = [] - self.collect_search_locations(path, locations) - - spec = importlib.util.spec_from_file_location(folder, target, submodule_search_locations = locations) - module = importlib.util.module_from_spec(spec) - sys.modules[folder] = module - spec.loader.exec_module(module) - - return module - - def collect_search_locations(self, path, locations): - locations.append(path) - for file in os.listdir(path): - _path = os.path.join(path, file) - if os.path.isdir(_path): - self.collect_search_locations(_path, locations) - - def execute_plugin(self, module: type, plugin: Plugin, loading_data: []): - plugin.reference = module.Plugin() - keys = loading_data.keys() - - if "ui_target" in keys: - loading_data["ui_target"].add( plugin.reference.generate_reference_ui_element() ) - loading_data["ui_target"].show_all() - - if "pass_ui_objects" in keys: - plugin.reference.set_ui_object_collection( loading_data["pass_ui_objects"] ) - - if "pass_events" in keys: - plugin.reference.set_fm_event_system(event_system) - plugin.reference.subscribe_to_events() - - if "bind_keys" in keys: - keybindings.append_bindings( loading_data["bind_keys"] ) - - plugin.reference.run() - self._plugin_collection.append(plugin) - - def reload_plugins(self, file: str = None) -> None: - logger.debug(f"Reloading plugins... stub.") diff --git a/src/utils/debugging.py b/src/utils/debugging.py index 97faff0..b84193a 100644 --- a/src/utils/debugging.py +++ b/src/utils/debugging.py @@ -19,12 +19,29 @@ def debug_signal_handler(signal, frame): rpdb2.setbreak(depth=1) return except StandardError: - pass + ... try: from rfoo.utils import rconsole logger.debug("\n\nStarting embedded rconsole debugger...\n\n") rconsole.spawn_server() + return + except StandardError as ex: + ... + + try: + from pudb import set_trace + logger.debug("\n\nStarting PuDB debugger...\n\n") + set_trace(paused = True) + return + except StandardError as ex: + ... + + try: + import pdb + logger.debug("\n\nStarting embedded PDB debugger...\n\n") + pdb.Pdb(skip=['gi.*']).set_trace() + return except StandardError as ex: ... diff --git a/src/utils/pulsectl/__init__.py b/src/utils/pulsectl/__init__.py new file mode 100644 index 0000000..6586ad1 --- /dev/null +++ b/src/utils/pulsectl/__init__.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +from __future__ import print_function + +from . import _pulsectl + +from .pulsectl import ( + PulsePortInfo, PulseClientInfo, PulseServerInfo, PulseModuleInfo, + PulseSinkInfo, PulseSinkInputInfo, PulseSourceInfo, PulseSourceOutputInfo, + PulseCardProfileInfo, PulseCardPortInfo, PulseCardInfo, PulseVolumeInfo, + PulseExtStreamRestoreInfo, PulseEventInfo, + + PulseEventTypeEnum, PulseEventFacilityEnum, PulseEventMaskEnum, + PulseStateEnum, PulseUpdateEnum, PulsePortAvailableEnum, PulseDirectionEnum, + + PulseError, PulseIndexError, PulseOperationFailed, PulseOperationInvalid, + PulseLoopStop, PulseDisconnected, PulseObject, Pulse, connect_to_cli ) diff --git a/src/utils/pulsectl/_pulsectl.py b/src/utils/pulsectl/_pulsectl.py new file mode 100644 index 0000000..036d410 --- /dev/null +++ b/src/utils/pulsectl/_pulsectl.py @@ -0,0 +1,688 @@ +# -*- coding: utf-8 -*- +from __future__ import print_function + + +# C Bindings + +import os, sys, ctypes.util, functools as ft +from ctypes import * + + +force_str = lambda s, errors='strict': s.decode('utf-8', errors) if isinstance(s, bytes) else s +force_bytes = lambda s, errors='strict': s.encode('utf-8', errors) if isinstance(s, unicode) else s + +if sys.version_info.major >= 3: + class c_str_p_type(object): + c_type = c_char_p + def __call__(self, val): return force_str(val) + def from_param(self, val): + # int will be interpreted as pointer and segfault in py3 + if isinstance(val, int): raise ArgumentError(type(val)) + return force_bytes(val) + unicode, c_str_p = str, c_str_p_type() + + import time + mono_time = time.monotonic + +else: + c_str_p = c_char_p + + def mono_time(): + if not hasattr(mono_time, 'ts'): + class timespec(Structure): + _fields_ = [('tv_sec', c_long), ('tv_nsec', c_long)] + librt = CDLL('librt.so.1', use_errno=True) + mono_time.get = librt.clock_gettime + mono_time.get.argtypes = [c_int, POINTER(timespec)] + mono_time.ts = timespec + ts = mono_time.ts() + if mono_time.get(4, pointer(ts)) != 0: + err = get_errno() + raise OSError(err, os.strerror(err)) + return ts.tv_sec + ts.tv_nsec * 1e-9 + + +PA_INVALID = 2**32-1 + +PA_VOLUME_NORM = 0x10000 +PA_VOLUME_MAX = (2**32-1) // 2 # was different before pulseaudio-1.0, see 179b291b there +PA_VOLUME_INVALID = 2**32-1 + +pa_sw_volume_from_dB = lambda db:\ + min(PA_VOLUME_MAX, int(round(((10.0 ** (db / 20.0)) ** (1/3)) * PA_VOLUME_NORM))) +PA_VOLUME_UI_MAX = 99957 # pa_sw_volume_from_dB(+11.0) + +PA_CHANNELS_MAX = 32 +PA_USEC_T = c_uint64 + +PA_CONTEXT_NOAUTOSPAWN = 0x0001 +PA_CONTEXT_NOFAIL = 0x0002 + +PA_CONTEXT_UNCONNECTED = 0 +PA_CONTEXT_CONNECTING = 1 +PA_CONTEXT_AUTHORIZING = 2 +PA_CONTEXT_SETTING_NAME = 3 +PA_CONTEXT_READY = 4 +PA_CONTEXT_FAILED = 5 +PA_CONTEXT_TERMINATED = 6 + +PA_SUBSCRIPTION_MASK_NULL = 0x0000 +PA_SUBSCRIPTION_MASK_SINK = 0x0001 +PA_SUBSCRIPTION_MASK_SOURCE = 0x0002 +PA_SUBSCRIPTION_MASK_SINK_INPUT = 0x0004 +PA_SUBSCRIPTION_MASK_SOURCE_OUTPUT = 0x0008 +PA_SUBSCRIPTION_MASK_MODULE = 0x0010 +PA_SUBSCRIPTION_MASK_CLIENT = 0x0020 +PA_SUBSCRIPTION_MASK_SAMPLE_CACHE = 0x0040 +PA_SUBSCRIPTION_MASK_SERVER = 0x0080 +PA_SUBSCRIPTION_MASK_AUTOLOAD = 0x0100 +PA_SUBSCRIPTION_MASK_CARD = 0x0200 +PA_SUBSCRIPTION_MASK_ALL = 0x02ff + +PA_SUBSCRIPTION_EVENT_SINK = 0x0000 +PA_SUBSCRIPTION_EVENT_SOURCE = 0x0001 +PA_SUBSCRIPTION_EVENT_SINK_INPUT = 0x0002 +PA_SUBSCRIPTION_EVENT_SOURCE_OUTPUT = 0x0003 +PA_SUBSCRIPTION_EVENT_MODULE = 0x0004 +PA_SUBSCRIPTION_EVENT_CLIENT = 0x0005 +PA_SUBSCRIPTION_EVENT_SAMPLE_CACHE = 0x0006 +PA_SUBSCRIPTION_EVENT_SERVER = 0x0007 +PA_SUBSCRIPTION_EVENT_AUTOLOAD = 0x0008 +PA_SUBSCRIPTION_EVENT_CARD = 0x0009 +PA_SUBSCRIPTION_EVENT_FACILITY_MASK = 0x000F +PA_SUBSCRIPTION_EVENT_NEW = 0x0000 +PA_SUBSCRIPTION_EVENT_CHANGE = 0x0010 +PA_SUBSCRIPTION_EVENT_REMOVE = 0x0020 +PA_SUBSCRIPTION_EVENT_TYPE_MASK = 0x0030 + +PA_SAMPLE_FLOAT32LE = 5 +PA_SAMPLE_FLOAT32BE = 6 +PA_SAMPLE_FLOAT32NE = dict( + little=PA_SAMPLE_FLOAT32LE, + big=PA_SAMPLE_FLOAT32BE )[sys.byteorder] + +PA_STREAM_DONT_MOVE = 0x0200 +PA_STREAM_PEAK_DETECT = 0x0800 +PA_STREAM_ADJUST_LATENCY = 0x2000 +PA_STREAM_DONT_INHIBIT_AUTO_SUSPEND = 0x8000 + +def c_enum_map(**values): + return dict((v, force_str(k)) for k,v in values.items()) + +_globals = globals().copy() +_pa_ev_type = dict( + (force_str(k), _globals['PA_SUBSCRIPTION_EVENT_{}'.format(k.upper())]) + for k in 'new change remove'.split() ) +_pa_ev_fac, _pa_ev_mask = dict(), dict() +for k, n in _globals.items(): + if k.startswith('PA_SUBSCRIPTION_EVENT_'): + if k.endswith('_MASK'): continue + k = force_str(k[22:].lower()) + if k in _pa_ev_type: continue + assert n & PA_SUBSCRIPTION_EVENT_FACILITY_MASK == n, [k, n] + _pa_ev_fac[k] = n + elif k.startswith('PA_SUBSCRIPTION_MASK_'): + _pa_ev_mask[force_str(k[21:].lower())] = n + +PA_EVENT_TYPE_MAP = c_enum_map(**_pa_ev_type) +PA_EVENT_FACILITY_MAP = c_enum_map(**_pa_ev_fac) +PA_EVENT_MASK_MAP = c_enum_map(**_pa_ev_mask) +del _globals, _pa_ev_type, _pa_ev_fac, _pa_ev_mask + +PA_UPDATE_MAP = c_enum_map(set=0, merge=1, replace=2) +PA_PORT_AVAILABLE_MAP = c_enum_map(unknown=0, no=1, yes=2) +PA_DIRECTION_MAP = c_enum_map(unknown=0, output=1, input=2) + +# These are defined separately as +# pa_sink_state / pa_source_state, but seem to match. +PA_OBJ_STATE_MAP = c_enum_map(invalid=-1, running=0, idle=1, suspended=2) + + +class PA_MAINLOOP(Structure): pass +class PA_STREAM(Structure): pass +class PA_MAINLOOP_API(Structure): pass +class PA_CONTEXT(Structure): pass +class PA_PROPLIST(Structure): pass +class PA_OPERATION(Structure): pass +class PA_SIGNAL_EVENT(Structure): pass +class PA_IO_EVENT(Structure): pass + + +class PA_SAMPLE_SPEC(Structure): + _fields_ = [ + ('format', c_int), + ('rate', c_uint32), + ('channels', c_uint32) + ] + +class PA_CHANNEL_MAP(Structure): + _fields_ = [ + ('channels', c_uint8), + ('map', c_int * PA_CHANNELS_MAX) + ] + +class PA_CVOLUME(Structure): + _fields_ = [ + ('channels', c_uint8), + ('values', c_uint32 * PA_CHANNELS_MAX) + ] + +class PA_PORT_INFO(Structure): + _fields_ = [ + ('name', c_char_p), + ('description', c_char_p), + ('priority', c_uint32), + ('available', c_int), + ] + +class PA_SINK_INPUT_INFO(Structure): + _fields_ = [ + ('index', c_uint32), + ('name', c_char_p), + ('owner_module', c_uint32), + ('client', c_uint32), + ('sink', c_uint32), + ('sample_spec', PA_SAMPLE_SPEC), + ('channel_map', PA_CHANNEL_MAP), + ('volume', PA_CVOLUME), + ('buffer_usec', PA_USEC_T), + ('sink_usec', PA_USEC_T), + ('resample_method', c_char_p), + ('driver', c_char_p), + ('mute', c_int), + ('proplist', POINTER(PA_PROPLIST)), + ('corked', c_int), + ('has_volume', c_int), + ('volume_writable', c_int), + ] + +class PA_SINK_INFO(Structure): + _fields_ = [ + ('name', c_char_p), + ('index', c_uint32), + ('description', c_char_p), + ('sample_spec', PA_SAMPLE_SPEC), + ('channel_map', PA_CHANNEL_MAP), + ('owner_module', c_uint32), + ('volume', PA_CVOLUME), + ('mute', c_int), + ('monitor_source', c_uint32), + ('monitor_source_name', c_char_p), + ('latency', PA_USEC_T), + ('driver', c_char_p), + ('flags', c_int), + ('proplist', POINTER(PA_PROPLIST)), + ('configured_latency', PA_USEC_T), + ('base_volume', c_uint32), + ('state', c_int), + ('n_volume_steps', c_int), + ('card', c_uint32), + ('n_ports', c_uint32), + ('ports', POINTER(POINTER(PA_PORT_INFO))), + ('active_port', POINTER(PA_PORT_INFO)), + ] + +class PA_SOURCE_OUTPUT_INFO(Structure): + _fields_ = [ + ('index', c_uint32), + ('name', c_char_p), + ('owner_module', c_uint32), + ('client', c_uint32), + ('source', c_uint32), + ('sample_spec', PA_SAMPLE_SPEC), + ('channel_map', PA_CHANNEL_MAP), + ('buffer_usec', PA_USEC_T), + ('source_usec', PA_USEC_T), + ('resample_method', c_char_p), + ('driver', c_char_p), + ('proplist', POINTER(PA_PROPLIST)), + ('corked', c_int), + ('volume', PA_CVOLUME), + ('mute', c_int), + ('has_volume', c_int), + ('volume_writable', c_int), + ] + +class PA_SOURCE_INFO(Structure): + _fields_ = [ + ('name', c_char_p), + ('index', c_uint32), + ('description', c_char_p), + ('sample_spec', PA_SAMPLE_SPEC), + ('channel_map', PA_CHANNEL_MAP), + ('owner_module', c_uint32), + ('volume', PA_CVOLUME), + ('mute', c_int), + ('monitor_of_sink', c_uint32), + ('monitor_of_sink_name', c_char_p), + ('latency', PA_USEC_T), + ('driver', c_char_p), + ('flags', c_int), + ('proplist', POINTER(PA_PROPLIST)), + ('configured_latency', PA_USEC_T), + ('base_volume', c_uint32), + ('state', c_int), + ('n_volume_steps', c_int), + ('card', c_uint32), + ('n_ports', c_uint32), + ('ports', POINTER(POINTER(PA_PORT_INFO))), + ('active_port', POINTER(PA_PORT_INFO)), + ] + +class PA_CLIENT_INFO(Structure): + _fields_ = [ + ('index', c_uint32), + ('name', c_char_p), + ('owner_module', c_uint32), + ('driver', c_char_p), + ('proplist', POINTER(PA_PROPLIST)), + ] + +class PA_SERVER_INFO(Structure): + _fields_ = [ + ('user_name', c_char_p), + ('host_name', c_char_p), + ('server_version', c_char_p), + ('server_name', c_char_p), + ('sample_spec', PA_SAMPLE_SPEC), + ('default_sink_name', c_char_p), + ('default_source_name', c_char_p), + ('cookie', c_uint32), + ('channel_map', PA_CHANNEL_MAP), + ] + +class PA_CARD_PROFILE_INFO(Structure): + _fields_ = [ + ('name', c_char_p), + ('description', c_char_p), + ('n_sinks', c_uint32), + ('n_sources', c_uint32), + ('priority', c_uint32), + ('available', c_int), + ] + +# Extends PA_PORT_INFO with a few card-specific things +class PA_CARD_PORT_INFO(Structure): + _fields_ = [ + ('name', c_char_p), + ('description', c_char_p), + ('priority', c_uint32), + ('available', c_int), + ('direction', c_int), + ('n_profiles', c_uint32), + ('profiles', c_void_p), # use profiles2 + ('proplist', POINTER(PA_PROPLIST)), + ('latency_offset', c_int64), + ('profiles2', POINTER(POINTER(PA_CARD_PROFILE_INFO))), + ] + +class PA_CARD_INFO(Structure): + _fields_ = [ + ('index', c_uint32), + ('name', c_char_p), + ('owner_module', c_uint32), + ('driver', c_char_p), + ('n_profiles', c_uint32), + ('profiles', c_void_p), # use profiles2 / active_profile2 + ('active_profile', c_void_p), + ('proplist', POINTER(PA_PROPLIST)), + ('n_ports', c_uint32), + ('ports', POINTER(POINTER(PA_CARD_PORT_INFO))), + ('profiles2', POINTER(POINTER(PA_CARD_PROFILE_INFO))), + ('active_profile2', POINTER(PA_CARD_PROFILE_INFO)), + ] + +class PA_MODULE_INFO(Structure): + _fields_ = [ + ('index', c_uint32), + ('name', c_char_p), + ('argument', c_char_p), + ('n_used', c_uint32), + ('auto_unload', c_int), + ('proplist', POINTER(PA_PROPLIST)), + ] + +class PA_EXT_STREAM_RESTORE_INFO(Structure): + _fields_ = [ + ('name', c_char_p), + ('channel_map', PA_CHANNEL_MAP), + ('volume', PA_CVOLUME), + ('device', c_char_p), + ('mute', c_int), + ] + +class PA_BUFFER_ATTR(Structure): + _fields_ = [ + ('maxlength', c_uint32), + ('tlength', c_uint32), + ('prebuf', c_uint32), + ('minreq', c_uint32), + ('fragsize', c_uint32), + ] + + +class POLLFD(Structure): + _fields_ = [ + ('fd', c_int), + ('events', c_short), + ('revents', c_short), + ] + + +PA_POLL_FUNC_T = CFUNCTYPE(c_int, + POINTER(POLLFD), + c_ulong, + c_int, + c_void_p) + +PA_SIGNAL_CB_T = CFUNCTYPE(c_void_p, + POINTER(PA_MAINLOOP_API), + POINTER(c_int), + c_int, + c_void_p) + +PA_STATE_CB_T = CFUNCTYPE(c_void_p, + POINTER(PA_CONTEXT), + c_void_p) + +PA_CLIENT_INFO_CB_T = CFUNCTYPE(c_void_p, + POINTER(PA_CONTEXT), + POINTER(PA_CLIENT_INFO), + c_int, + c_void_p) + +PA_SERVER_INFO_CB_T = CFUNCTYPE(c_void_p, + POINTER(PA_CONTEXT), + POINTER(PA_SERVER_INFO), + c_void_p) + +PA_SINK_INPUT_INFO_CB_T = CFUNCTYPE(c_void_p, + POINTER(PA_CONTEXT), + POINTER(PA_SINK_INPUT_INFO), + c_int, + c_void_p) + +PA_SINK_INFO_CB_T = CFUNCTYPE(c_void_p, + POINTER(PA_CONTEXT), + POINTER(PA_SINK_INFO), + c_int, + c_void_p) + +PA_SOURCE_OUTPUT_INFO_CB_T = CFUNCTYPE(c_void_p, + POINTER(PA_CONTEXT), + POINTER(PA_SOURCE_OUTPUT_INFO), + c_int, + c_void_p) + +PA_SOURCE_INFO_CB_T = CFUNCTYPE(c_void_p, + POINTER(PA_CONTEXT), + POINTER(PA_SOURCE_INFO), + c_int, + c_void_p) + +PA_CONTEXT_DRAIN_CB_T = CFUNCTYPE(c_void_p, + POINTER(PA_CONTEXT), + c_void_p) + +PA_CONTEXT_INDEX_CB_T = CFUNCTYPE(c_void_p, + POINTER(PA_CONTEXT), + c_uint32, + c_void_p) + +PA_CONTEXT_SUCCESS_CB_T = CFUNCTYPE(c_void_p, + POINTER(PA_CONTEXT), + c_int, + c_void_p) + +PA_EXT_STREAM_RESTORE_TEST_CB_T = CFUNCTYPE(c_void_p, + POINTER(PA_CONTEXT), + c_uint32, + c_void_p) + +PA_EXT_STREAM_RESTORE_READ_CB_T = CFUNCTYPE(c_void_p, + POINTER(PA_CONTEXT), + POINTER(PA_EXT_STREAM_RESTORE_INFO), + c_int, + c_void_p) + +PA_CARD_INFO_CB_T = CFUNCTYPE(c_void_p, + POINTER(PA_CONTEXT), + POINTER(PA_CARD_INFO), + c_int, + c_void_p) + +PA_MODULE_INFO_CB_T = CFUNCTYPE(c_void_p, + POINTER(PA_CONTEXT), + POINTER(PA_MODULE_INFO), + c_int, + c_void_p) + +PA_SUBSCRIBE_CB_T = CFUNCTYPE(c_void_p, + POINTER(PA_CONTEXT), + c_int, + c_int, + c_void_p) + +PA_STREAM_REQUEST_CB_T = CFUNCTYPE(c_void_p, + POINTER(PA_STREAM), + c_int, + c_void_p) + +PA_STREAM_NOTIFY_CB_T = CFUNCTYPE(c_void_p, + POINTER(PA_STREAM), + c_void_p) + + +class LibPulse(object): + + # func_def ::= arg_types_list | (arg_types_list, res_spec) | (res_spec, arg_types_list) + # res_spec ::= ctypes_restype + # | res_proc_func | (ctypes_restype, res_proc_func) + # | res_spec_name_str | (ctypes_restype, res_spec_name_str) + # res_spec_name_str ::= 'int_check_ge0' | 'pa_op' | ... + func_defs = dict( + pa_strerror=([c_int], c_str_p), + pa_runtime_path=([c_str_p], (c_char_p, 'not_null')), + pa_operation_unref=[POINTER(PA_OPERATION)], + + pa_mainloop_new=(POINTER(PA_MAINLOOP)), + pa_mainloop_get_api=([POINTER(PA_MAINLOOP)], POINTER(PA_MAINLOOP_API)), + pa_mainloop_run=([POINTER(PA_MAINLOOP), POINTER(c_int)], c_int), + pa_mainloop_prepare=([POINTER(PA_MAINLOOP), c_int], 'int_check_ge0'), + pa_mainloop_poll=([POINTER(PA_MAINLOOP)], 'int_check_ge0'), + pa_mainloop_dispatch=([POINTER(PA_MAINLOOP)], 'int_check_ge0'), + pa_mainloop_iterate=([POINTER(PA_MAINLOOP), c_int, POINTER(c_int)], 'int_check_ge0'), + pa_mainloop_wakeup=[POINTER(PA_MAINLOOP)], + pa_mainloop_set_poll_func=[POINTER(PA_MAINLOOP), PA_POLL_FUNC_T, c_void_p], + pa_mainloop_quit=([POINTER(PA_MAINLOOP), c_int]), + pa_mainloop_free=[POINTER(PA_MAINLOOP)], + + pa_signal_init=([POINTER(PA_MAINLOOP_API)], 'int_check_ge0'), + pa_signal_new=([c_int, PA_SIGNAL_CB_T, POINTER(PA_SIGNAL_EVENT)]), + pa_signal_done=None, + + pa_context_errno=([POINTER(PA_CONTEXT)], c_int), + pa_context_new=([POINTER(PA_MAINLOOP_API), c_str_p], POINTER(PA_CONTEXT)), + pa_context_set_state_callback=([POINTER(PA_CONTEXT), PA_STATE_CB_T, c_void_p]), + pa_context_connect=([POINTER(PA_CONTEXT), c_str_p, c_int, POINTER(c_int)], 'int_check_ge0'), + pa_context_get_state=([POINTER(PA_CONTEXT)], c_int), + pa_context_disconnect=[POINTER(PA_CONTEXT)], + pa_context_unref=[POINTER(PA_CONTEXT)], + pa_context_drain=( 'pa_op', + [POINTER(PA_CONTEXT), PA_CONTEXT_DRAIN_CB_T, c_void_p] ), + pa_context_set_default_sink=( 'pa_op', + [POINTER(PA_CONTEXT), c_str_p, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), + pa_context_set_default_source=( 'pa_op', + [POINTER(PA_CONTEXT), c_str_p, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), + pa_context_get_sink_input_info_list=( 'pa_op', + [POINTER(PA_CONTEXT), PA_SINK_INPUT_INFO_CB_T, c_void_p] ), + pa_context_get_sink_input_info=( 'pa_op', + [POINTER(PA_CONTEXT), c_uint32, PA_SINK_INPUT_INFO_CB_T, c_void_p] ), + pa_context_get_sink_info_list=( 'pa_op', + [POINTER(PA_CONTEXT), PA_SINK_INFO_CB_T, c_void_p] ), + pa_context_get_sink_info_by_name=( 'pa_op', + [POINTER(PA_CONTEXT), c_str_p, PA_SINK_INFO_CB_T, c_void_p] ), + pa_context_get_sink_info_by_index=( 'pa_op', + [POINTER(PA_CONTEXT), c_uint32, PA_SINK_INFO_CB_T, c_void_p] ), + pa_context_set_sink_mute_by_index=( 'pa_op', + [POINTER(PA_CONTEXT), c_uint32, c_int, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), + pa_context_suspend_sink_by_index=( 'pa_op', + [POINTER(PA_CONTEXT), c_uint32, c_int, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), + pa_context_set_sink_port_by_index=( 'pa_op', + [POINTER(PA_CONTEXT), c_uint32, c_str_p, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), + pa_context_set_sink_input_mute=( 'pa_op', + [POINTER(PA_CONTEXT), c_uint32, c_int, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), + pa_context_set_sink_volume_by_index=( 'pa_op', + [POINTER(PA_CONTEXT), c_uint32, POINTER(PA_CVOLUME), PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), + pa_context_set_sink_input_volume=( 'pa_op', + [POINTER(PA_CONTEXT), c_uint32, POINTER(PA_CVOLUME), PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), + pa_context_move_sink_input_by_index=( 'pa_op', + [POINTER(PA_CONTEXT), c_uint32, c_uint32, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), + pa_context_get_source_output_info=( 'pa_op', + [POINTER(PA_CONTEXT), c_uint32, PA_SOURCE_OUTPUT_INFO_CB_T, c_void_p] ), + pa_context_get_source_output_info_list=( 'pa_op', + [POINTER(PA_CONTEXT), PA_SOURCE_OUTPUT_INFO_CB_T, c_void_p] ), + pa_context_move_source_output_by_index=( 'pa_op', + [POINTER(PA_CONTEXT), c_uint32, c_uint32, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), + pa_context_set_source_output_volume=( 'pa_op', + [POINTER(PA_CONTEXT), c_uint32, POINTER(PA_CVOLUME), PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), + pa_context_set_source_output_mute=( 'pa_op', + [POINTER(PA_CONTEXT), c_uint32, c_int, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), + pa_context_kill_source_output=( 'pa_op', + [POINTER(PA_CONTEXT), c_uint32, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), + pa_context_get_source_info_list=( 'pa_op', + [POINTER(PA_CONTEXT), PA_SOURCE_INFO_CB_T, c_void_p] ), + pa_context_get_source_info_by_name=( 'pa_op', + [POINTER(PA_CONTEXT), c_str_p, PA_SOURCE_INFO_CB_T, c_void_p] ), + pa_context_get_source_info_by_index=( 'pa_op', + [POINTER(PA_CONTEXT), c_uint32, PA_SOURCE_INFO_CB_T, c_void_p] ), + pa_context_set_source_volume_by_index=( 'pa_op', + [POINTER(PA_CONTEXT), c_uint32, POINTER(PA_CVOLUME), PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), + pa_context_set_source_mute_by_index=( 'pa_op', + [POINTER(PA_CONTEXT), c_uint32, c_int, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), + pa_context_suspend_source_by_index=( 'pa_op', + [POINTER(PA_CONTEXT), c_uint32, c_int, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), + pa_context_set_source_port_by_index=( 'pa_op', + [POINTER(PA_CONTEXT), c_uint32, c_str_p, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), + pa_context_get_client_info_list=( 'pa_op', + [POINTER(PA_CONTEXT), PA_CLIENT_INFO_CB_T, c_void_p] ), + pa_context_get_client_info=( 'pa_op', + [POINTER(PA_CONTEXT), c_uint32, PA_CLIENT_INFO_CB_T, c_void_p] ), + pa_context_get_server_info=( 'pa_op', + [POINTER(PA_CONTEXT), PA_SERVER_INFO_CB_T, c_void_p] ), + pa_context_get_card_info_by_index=( 'pa_op', + [POINTER(PA_CONTEXT), c_uint32, PA_CARD_INFO_CB_T, c_void_p] ), + pa_context_get_card_info_by_name=( 'pa_op', + [POINTER(PA_CONTEXT), c_str_p, PA_CARD_INFO_CB_T, c_void_p] ), + pa_context_get_card_info_list=( 'pa_op', + [POINTER(PA_CONTEXT), PA_CARD_INFO_CB_T, c_void_p] ), + pa_context_set_card_profile_by_index=( 'pa_op', + [POINTER(PA_CONTEXT), c_uint32, c_str_p, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), + pa_context_get_module_info=( 'pa_op', + [POINTER(PA_CONTEXT), c_uint32, PA_MODULE_INFO_CB_T, c_void_p] ), + pa_context_get_module_info_list=( 'pa_op', + [POINTER(PA_CONTEXT), PA_MODULE_INFO_CB_T, c_void_p] ), + pa_context_load_module=( 'pa_op', + [POINTER(PA_CONTEXT), c_char_p, c_char_p, PA_CONTEXT_INDEX_CB_T, c_void_p] ), + pa_context_unload_module=( 'pa_op', + [POINTER(PA_CONTEXT), c_uint32, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), + pa_context_subscribe=( 'pa_op', + [POINTER(PA_CONTEXT), c_int, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), + pa_context_set_subscribe_callback=[POINTER(PA_CONTEXT), PA_SUBSCRIBE_CB_T, c_void_p], + pa_context_play_sample=( 'pa_op', + [POINTER(PA_CONTEXT), c_str_p, c_str_p, c_uint32, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), + pa_context_play_sample_with_proplist=( 'pa_op', + [ POINTER(PA_CONTEXT), c_str_p, c_str_p, c_uint32, + POINTER(PA_PROPLIST), PA_CONTEXT_SUCCESS_CB_T, c_void_p ] ), + + pa_ext_stream_restore_test=( 'pa_op', + [POINTER(PA_CONTEXT), PA_EXT_STREAM_RESTORE_TEST_CB_T, c_void_p] ), + pa_ext_stream_restore_read=( 'pa_op', + [POINTER(PA_CONTEXT), PA_EXT_STREAM_RESTORE_READ_CB_T, c_void_p] ), + pa_ext_stream_restore_write=( 'pa_op', [ + POINTER(PA_CONTEXT), c_int, POINTER(PA_EXT_STREAM_RESTORE_INFO), + c_uint, c_int, PA_CONTEXT_SUCCESS_CB_T, c_void_p ] ), + pa_ext_stream_restore_delete=( 'pa_op', + [POINTER(PA_CONTEXT), POINTER(c_char_p), PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), + + pa_proplist_from_string=([c_str_p], POINTER(PA_PROPLIST)), + pa_proplist_iterate=([POINTER(PA_PROPLIST), POINTER(c_void_p)], c_str_p), + pa_proplist_gets=([POINTER(PA_PROPLIST), c_str_p], c_str_p), + pa_proplist_free=[POINTER(PA_PROPLIST)], + + pa_channel_map_init_mono=( + [POINTER(PA_CHANNEL_MAP)], (POINTER(PA_CHANNEL_MAP), 'not_null') ), + pa_channel_map_init_stereo=( + [POINTER(PA_CHANNEL_MAP)], (POINTER(PA_CHANNEL_MAP), 'not_null') ), + pa_channel_map_snprint=([c_str_p, c_int, POINTER(PA_CHANNEL_MAP)], c_str_p), + pa_channel_map_parse=( + [POINTER(PA_CHANNEL_MAP), c_str_p], (POINTER(PA_CHANNEL_MAP), 'not_null') ), + pa_channel_position_to_string=([c_int], c_str_p), + + pa_stream_new_with_proplist=( + [ POINTER(PA_CONTEXT), c_str_p, + POINTER(PA_SAMPLE_SPEC), POINTER(PA_CHANNEL_MAP), POINTER(PA_PROPLIST) ], + POINTER(PA_STREAM) ), + pa_stream_set_monitor_stream=([POINTER(PA_STREAM), c_uint32], 'int_check_ge0'), + pa_stream_set_read_callback=[POINTER(PA_STREAM), PA_STREAM_REQUEST_CB_T, c_void_p], + pa_stream_connect_record=( + [POINTER(PA_STREAM), c_str_p, POINTER(PA_BUFFER_ATTR), c_int], 'int_check_ge0' ), + pa_stream_unref=[POINTER(PA_STREAM)], + pa_stream_peek=( + [POINTER(PA_STREAM), POINTER(c_void_p), POINTER(c_int)], 'int_check_ge0' ), + pa_stream_drop=([POINTER(PA_STREAM)], 'int_check_ge0'), + pa_stream_disconnect=([POINTER(PA_STREAM)], 'int_check_ge0') ) + + class CallError(Exception): pass + + + def __init__(self): + p = CDLL(ctypes.util.find_library('libpulse') or 'libpulse.so.0') + + self.funcs = dict() + for k, spec in self.func_defs.items(): + func, args, res_proc = getattr(p, k), None, None + if spec: + if not isinstance(spec, tuple): spec = (spec,) + for v in spec: + assert v, [k, spec, v] + if isinstance(v, list): args = v + else: res_proc = v + func_k = k if not k.startswith('pa_') else k[3:] + self.funcs[func_k] = self._func_wrapper(k, func, args, res_proc) + + def _func_wrapper(self, func_name, func, args=list(), res_proc=None): + func.restype, func.argtypes = None, args + if isinstance(res_proc, tuple): func.restype, res_proc = res_proc + if isinstance(res_proc, str): + if res_proc.startswith('int_check_'): func.restype = c_int + elif res_proc == 'pa_op': func.restype = POINTER(PA_OPERATION) + elif not func.restype and hasattr(res_proc, 'c_type'): func.restype = res_proc.c_type + elif not func.restype: func.restype, res_proc = res_proc, None + + def _wrapper(*args): + # print('libpulse call:', func_name, args, file=sys.stderr) + # sys.stderr.flush() + res = func(*args) + if isinstance(res_proc, str): + assert res_proc in ['int_check_ge0', 'pa_op', 'not_null'] + if (res_proc == 'int_check_ge0' and res < 0)\ + or (res_proc == 'pa_op' and not res)\ + or (res_proc == 'not_null' and not res): + err = [func_name, args, res] + if args and isinstance(getattr(args[0], 'contents', None), PA_CONTEXT): + errno_ = self.context_errno(args[0]) + err.append('{} [pulse errno {}]'.format(self.strerror(errno_), errno_)) + else: err.append('Return value check failed: {}'.format(res_proc)) + raise self.CallError(*err) + elif res_proc: res = res_proc(res) + return res + + _wrapper.__name__ = 'libpulse.{}'.format(func_name) + return _wrapper + + def __getattr__(self, k): return self.funcs[k] + + def return_value(self): return pointer(c_int()) + +pa = LibPulse() diff --git a/src/utils/pulsectl/lookup.py b/src/utils/pulsectl/lookup.py new file mode 100644 index 0000000..aceb11d --- /dev/null +++ b/src/utils/pulsectl/lookup.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +from __future__ import print_function, unicode_literals + +import itertools as it, operator as op, functools as ft +import re + + +lookup_types = { + 'sink': 'sink_list', 'source': 'source_list', + 'sink-input': 'sink_input_list', 'source-output': 'source_output_list' } +lookup_types.update(it.chain.from_iterable( + ((v, lookup_types[k]) for v in v) for k,v in + { 'source': ['src'], 'sink-input': ['si', 'playback', 'play'], + 'source-output': ['so', 'record', 'rec', 'mic'] }.items() )) + +lookup_key_defaults = dict( + # No default keys for type = no implicit matches for that type + sink_input_list=[ # match sink_input_list objects with these keys by default + 'media.name', 'media.icon_name', 'media.role', + 'application.name', 'application.process.binary', 'application.icon_name' ] ) + + +def pulse_obj_lookup(pulse, obj_lookup, prop_default=None): + '''Return set of pulse object(s) with proplist values matching lookup-string. + + Pattern syntax: + [ { 'sink' | 'source' | 'sink-input' | 'source-output' } [ / ... ] ':' ] + [ proplist-key-name (non-empty) [ / ... ] ':' ] [ ':' (for regexp match) ] + [ proplist-key-value ] + + Examples: + - sink:alsa.driver_name:snd_hda_intel + Match sink(s) with alsa.driver_name=snd_hda_intel (exact match!). + - sink/source:device.bus:pci + Match all sinks and sources with device.bus=pci. + - myprop:somevalue + Match any object (of all 4 supported types) that has myprop=somevalue. + - mpv + Match any object with any of the "default lookup props" (!!!) being equal to "mpv". + "default lookup props" are specified per-type in lookup_key_defaults above. + For example, sink input will be looked-up by media.name, application.name, etc. + - sink-input/source-output:mpv + Same as above, but lookup streams only (not sinks/sources). + Note that "sink-input/source-output" matches type spec, and parsed as such, not as key. + - si/so:mpv + Same as above - see aliases for types in lookup_types. + - application.binary/application.icon:mpv + Lookup by multiple keys with "any match" logic, same as with multiple object types. + - key\/with\/slashes\:and\:colons:somevalue + Lookup by key that has slashes and colons in it. + "/" and ":" must only be escaped in the proplist key part, used as-is in values. + Backslash itself can be escaped as well, i.e. as "\\". + - module-stream-restore.id:sink-input-by-media-role:music + Value has ":" in it, but there's no need to escape it in any way. + - device.description::Analog + Value lookup starting with : is interpreted as a regexp, + i.e. any object with device.description *containing* "Analog" in this case. + - si/so:application.name::^mpv\b + Return all sink-inputs/source-outputs ("si/so") where + "application.name" proplist value matches regexp "^mpv\b". + - :^mpv\b + Regexp lookup (stuff starting with "mpv" word) without type or key specification. + + For python2, lookup string should be unicode type. + "prop_default" keyword arg can be used to specify + default proplist value for when key is not found there.''' + + # \ue000-\uf8ff - private use area, never assigned to symbols + obj_lookup = obj_lookup.replace('\\\\', '\ue000').replace('\\:', '\ue001') + obj_types_re = '({0})(/({0}))*'.format('|'.join(lookup_types)) + m = re.search( + ( r'^((?P{}):)?'.format(obj_types_re) + + r'((?P.+?):)?' r'(?P.*)$' ), obj_lookup, re.IGNORECASE ) + if not m: raise ValueError(obj_lookup) + lookup_type, lookup_keys, lookup_re = op.itemgetter('t', 'k', 'v')(m.groupdict()) + if lookup_keys: + lookup_keys = list( + v.replace('\ue000', '\\\\').replace('\ue001', ':').replace('\ue002', '/') + for v in lookup_keys.replace('\\/', '\ue002').split('/') ) + lookup_re = lookup_re.replace('\ue000', '\\\\').replace('\ue001', '\\:') + obj_list_res, lookup_re = list(), re.compile( lookup_re[1:] + if lookup_re.startswith(':') else '^{}$'.format(re.escape(lookup_re)) ) + for k in set( lookup_types[k] for k in + (lookup_type.split('/') if lookup_type else lookup_types.keys()) ): + if not lookup_keys: lookup_keys = lookup_key_defaults.get(k) + if not lookup_keys: continue + obj_list = getattr(pulse, k)() + if not obj_list: continue + for obj, k in it.product(obj_list, lookup_keys): + v = obj.proplist.get(k, prop_default) + if v is None: continue + if lookup_re.search(v): obj_list_res.append(obj) + return set(obj_list_res) diff --git a/src/utils/pulsectl/pulsectl.py b/src/utils/pulsectl/pulsectl.py new file mode 100644 index 0000000..8970008 --- /dev/null +++ b/src/utils/pulsectl/pulsectl.py @@ -0,0 +1,992 @@ +# -*- coding: utf-8 -*- +from __future__ import print_function + +import itertools as it, operator as op, functools as ft +from collections import defaultdict +from contextlib import contextmanager +import os, sys, inspect, traceback + +from . import _pulsectl as c + + +if sys.version_info.major >= 3: + long, unicode = int, str + print_err = ft.partial(print, file=sys.stderr, flush=True) + def wrapper_with_sig_info(func, wrapper, index_arg=False): + sig = inspect.signature(func or (lambda: None)) + if index_arg: + sig = sig.replace(parameters=[inspect.Parameter( 'index', + inspect.Parameter.POSITIONAL_OR_KEYWORD )] + list(sig.parameters.values())) + wrapper.__name__, wrapper.__signature__, wrapper.__doc__ = '', sig, func.__doc__ + return wrapper + +else: + range, map = xrange, it.imap + def print_err(*args, **kws): + kws.setdefault('file', sys.stderr) + print(*args, **kws) + kws['file'].flush() + def wrapper_with_sig_info(func, wrapper, index_arg=False): + func_args = list(inspect.getargspec(func or (lambda: None))) + func_args[0] = list(func_args[0]) + if index_arg: func_args[0] = ['index'] + func_args[0] + wrapper.__name__ = '...' + wrapper.__doc__ = 'Signature: func' + inspect.formatargspec(*func_args) + if func.__doc__: wrapper.__doc__ += '\n\n' + func.__doc__ + return wrapper + + +is_str = lambda v,ext=None,native=False: ( + isinstance(v, ( (unicode, bytes) + if not native else (str,) ) + ((ext,) if ext else ())) ) +is_str_native = ft.partial(is_str, native=True) +is_num = lambda v: isinstance(v, (int, float, long)) +is_list = lambda v: isinstance(v, (tuple, list)) +is_dict = lambda v: isinstance(v, dict) + +def assert_pulse_object(obj): + if not isinstance(obj, PulseObject): + raise TypeError( 'PulseInfo' + ' object is required instead of value: [{}] {}', type(obj), obj ) + +class FakeLock(): + def __enter__(self): return self + def __exit__(self, *err): pass + + +@ft.total_ordering +class EnumValue(object): + 'String-based enum value, comparable to native strings.' + __slots__ = '_t', '_value', '_c_val' + def __init__(self, t, value, c_value=None): + self._t, self._value, self._c_val = t, value, c_value + def __repr__(self): return ''.format(self._t, self._value) + def __eq__(self, val): + if isinstance(val, EnumValue): val = val._value + return self._value == val + def __ne__(self, val): return not (self == val) + def __lt__(self, val): + if isinstance(val, EnumValue): val = val._value + return self._value < val + def __hash__(self): return hash(self._value) + +class Enum(object): + + def __init__(self, name, values_or_map): + vals = values_or_map + if is_str_native(vals): vals = vals.split() + if is_list(vals): vals = zip(it.repeat(None), vals) + if is_dict(vals): vals = vals.items() + self._name, self._values, self._c_vals = name, dict(), dict() + for c_val, k in vals: + v = EnumValue(name, k, c_val) + setattr(self, k.replace('-', '_'), v) + self._c_vals[c_val] = self._values[k] = v + + def __getitem__(self, k, *default): + if isinstance(k, EnumValue): + t, k, v = k._t, k._value, k + if t != self._name: raise KeyError(v) + try: return getattr(self, k.replace('-', '_'), *default) + except AttributeError: raise KeyError(k) + + def _get(self, k, default=None): return self.__getitem__(k, default) + def __contains__(self, k): return self._get(k) is not None + + def _c_val(self, c_val, default=KeyError): + v = self._c_vals.get(c_val) + if v is not None: return v + if default is not KeyError: + return EnumValue(self._name, default, c_val) + raise KeyError(c_val) + + def __repr__(self): + return ''.format(self._name, ' '.join(sorted(self._values.keys()))) + + +PulseEventTypeEnum = Enum('event-type', c.PA_EVENT_TYPE_MAP) +PulseEventFacilityEnum = Enum('event-facility', c.PA_EVENT_FACILITY_MAP) +PulseEventMaskEnum = Enum('event-mask', c.PA_EVENT_MASK_MAP) + +PulseStateEnum = Enum('sink/source-state', c.PA_OBJ_STATE_MAP) +PulseUpdateEnum = Enum('update-type', c.PA_UPDATE_MAP) +PulsePortAvailableEnum = Enum('available', c.PA_PORT_AVAILABLE_MAP) +PulseDirectionEnum = Enum('direction', c.PA_DIRECTION_MAP) + + +class PulseError(Exception): pass +class PulseOperationFailed(PulseError): pass +class PulseOperationInvalid(PulseOperationFailed): pass +class PulseIndexError(PulseError): pass + +class PulseLoopStop(Exception): pass +class PulseDisconnected(Exception): pass + +class PulseObject(object): + + c_struct_wrappers = dict() + + def __init__(self, struct=None, *field_data_list, **field_data_dict): + field_data, fields = dict(), getattr(self, 'c_struct_fields', list()) + if is_str_native(fields): fields = self.c_struct_fields = fields.split() + if field_data_list: field_data.update(zip(fields, field_data_list)) + if field_data_dict: field_data.update(field_data_dict) + if struct is None: field_data, struct = dict(), field_data + assert not set(field_data.keys()).difference(fields) + if field_data: self._copy_struct_fields(field_data, fields=field_data.keys()) + self._copy_struct_fields(struct, fields=set(fields).difference(field_data.keys())) + + if struct: + if hasattr(struct, 'proplist'): + self.proplist, state = dict(), c.c_void_p() + while True: + k = c.pa.proplist_iterate(struct.proplist, c.byref(state)) + if not k: break + self.proplist[c.force_str(k)] = c.force_str(c.pa.proplist_gets(struct.proplist, k)) + if hasattr(struct, 'volume'): + self.volume = self._get_wrapper(PulseVolumeInfo)(struct.volume) + if hasattr(struct, 'base_volume'): + self.base_volume = struct.base_volume / c.PA_VOLUME_NORM + if hasattr(struct, 'n_ports'): + cls_port = self._get_wrapper(PulsePortInfo) + self.port_list = list( + cls_port(struct.ports[n].contents) for n in range(struct.n_ports) ) + if hasattr(struct, 'active_port'): + cls_port = self._get_wrapper(PulsePortInfo) + self.port_active = ( + None if not struct.active_port else cls_port(struct.active_port.contents) ) + if hasattr(struct, 'channel_map'): + self.channel_count, self.channel_list = struct.channel_map.channels, list() + self.channel_list_raw = struct.channel_map.map[:self.channel_count] + if self.channel_count > 0: + s = c.create_string_buffer(b'\0' * 512) + c.pa.channel_map_snprint(s, len(s), struct.channel_map) + self.channel_list.extend(map(c.force_str, s.value.strip().split(b','))) + if hasattr(struct, 'state'): + self.state = PulseStateEnum._c_val( + struct.state, u'state.{}'.format(struct.state) ) + self.state_values = sorted(PulseStateEnum._values.values()) + if hasattr(struct, 'corked'): self.corked = bool(struct.corked) + self._init_from_struct(struct) + + def _get_wrapper(self, cls_base): + return self.c_struct_wrappers.get(cls_base, cls_base) + + def _copy_struct_fields(self, struct, fields=None, str_errors='strict'): + if not fields: fields = self.c_struct_fields + for k in fields: + setattr(self, k, c.force_str( getattr(struct, k) + if not is_dict(struct) else struct[k], str_errors )) + + def _init_from_struct(self, struct): pass # to parse fields in subclasses + + def _as_str(self, ext=None, fields=None, **kws): + kws = list(it.starmap('{}={}'.format, kws.items())) + if fields: + if is_str_native(fields): fields = fields.split() + kws.extend('{}={!r}'.format(k, getattr(self, k)) for k in fields) + kws = sorted(kws) + if ext: kws.append(str(ext)) + return ', '.join(kws) + + def __str__(self): + return self._as_str(fields=self.c_struct_fields) + + def __repr__(self): + return '<{} at {:x} - {}>'.format(self.__class__.__name__, id(self), str(self)) + + +class PulsePortInfo(PulseObject): + c_struct_fields = 'name description available priority' + + def _init_from_struct(self, struct): + self.available = PulsePortAvailableEnum._c_val(struct.available) + self.available_state = self.available # for compatibility with <=17.6.0 + + def __eq__(self, o): + if not isinstance(o, PulsePortInfo): raise TypeError(o) + return self.name == o.name + + def __hash__(self): return hash(self.name) + +class PulseClientInfo(PulseObject): + c_struct_fields = 'name index driver owner_module' + +class PulseServerInfo(PulseObject): + c_struct_fields = ( 'user_name host_name' + ' server_version server_name default_sink_name default_source_name cookie' ) + +class PulseModuleInfo(PulseObject): + c_struct_fields = 'index name argument n_used auto_unload' + +class PulseSinkInfo(PulseObject): + c_struct_fields = ( 'index name mute' + ' description sample_spec owner_module latency driver' + ' monitor_source monitor_source_name flags configured_latency card' ) + + def __str__(self): + return self._as_str(self.volume, fields='index name description mute') + +class PulseSinkInputInfo(PulseObject): + c_struct_fields = ( 'index name mute corked client' + ' owner_module sink sample_spec' + ' buffer_usec sink_usec resample_method driver' ) + + def __str__(self): + return self._as_str(fields='index name mute') + +class PulseSourceInfo(PulseObject): + c_struct_fields = ( 'index name mute' + ' description sample_spec owner_module latency driver monitor_of_sink' + ' monitor_of_sink_name flags configured_latency card' ) + + def __str__(self): + return self._as_str(self.volume, fields='index name description mute') + +class PulseSourceOutputInfo(PulseObject): + c_struct_fields = ( 'index name mute corked client' + ' owner_module source sample_spec' + ' buffer_usec source_usec resample_method driver' ) + + def __str__(self): + return self._as_str(fields='index name mute') + +class PulseCardProfileInfo(PulseObject): + c_struct_fields = 'name description n_sinks n_sources priority available' + +class PulseCardPortInfo(PulsePortInfo): + c_struct_fields = 'name description available priority direction latency_offset' + + def _init_from_struct(self, struct): + super(PulseCardPortInfo, self)._init_from_struct(struct) + self.direction = PulseDirectionEnum._c_val(struct.direction) + +class PulseCardInfo(PulseObject): + c_struct_fields = 'name index driver owner_module n_profiles' + c_struct_wrappers = {PulsePortInfo: PulseCardPortInfo} + + def __init__(self, struct): + super(PulseCardInfo, self).__init__(struct) + self.profile_list = list( + PulseCardProfileInfo(struct.profiles2[n][0]) for n in range(self.n_profiles) ) + self.profile_active = PulseCardProfileInfo(struct.active_profile2.contents) + + def __str__(self): + return self._as_str( + fields='name index driver n_profiles', + profile_active='[{}]'.format(self.profile_active.name) ) + +class PulseVolumeInfo(PulseObject): + + def __init__(self, struct_or_values=None, channels=None): + if is_num(struct_or_values): + assert channels is not None, 'Channel count specified if volume value is not a list.' + self.values = [struct_or_values] * channels + elif is_list(struct_or_values): self.values = struct_or_values + else: + self.values = list( (x / c.PA_VOLUME_NORM) + for x in map(float, struct_or_values.values[:struct_or_values.channels]) ) + + @property + def value_flat(self): return (sum(self.values) / float(len(self.values))) if self.values else 0 + @value_flat.setter + def value_flat(self, v): self.values = [v] * len(self.values) + + def to_struct(self): + return c.PA_CVOLUME( + len(self.values), tuple(min( c.PA_VOLUME_UI_MAX, + int(round(v * c.PA_VOLUME_NORM)) ) for v in self.values) ) + + def __str__(self): + return self._as_str( + channels=len(self.values), volumes='[{}]'.format( + ' '.join('{}%'.format(int(round(v*100))) for v in self.values) ) ) + +class PulseExtStreamRestoreInfo(PulseObject): + c_struct_fields = 'name channel_map volume mute device' + + @classmethod + def struct_from_value( cls, name, volume, + channel_list=None, mute=False, device=None ): + 'Same arguments as with class instance init.' + chan_map = c.PA_CHANNEL_MAP() + if not channel_list: c.pa.channel_map_init_mono(chan_map) + else: + if not is_str(channel_list): + channel_list = b','.join(map(c.force_bytes, channel_list)) + c.pa.channel_map_parse(chan_map, channel_list) + if not isinstance(volume, PulseVolumeInfo): + volume = PulseVolumeInfo(volume, chan_map.channels) + struct = c.PA_EXT_STREAM_RESTORE_INFO( + name=c.force_bytes(name), + mute=int(bool(mute)), device=c.force_bytes(device), + channel_map=chan_map, volume=volume.to_struct() ) + return struct + + def __init__( self, struct_or_name=None, + volume=None, channel_list=None, mute=False, device=None ): + '''If string name is passed instead of C struct, will be initialized from args/kws. + "volume" can be either a float number + (same level for all channels) or list (value per channel). + "channel_list" can be a pulse channel map string (comma-separated) or list + of channel names. Defaults to stereo map, should probably match volume channels. + "device" - name of sink/source or None (default).''' + if is_str(struct_or_name): + struct_or_name = self.struct_from_value( + struct_or_name, volume, channel_list, mute, device ) + super(PulseExtStreamRestoreInfo, self).__init__(struct_or_name) + + def to_struct(self): + return self.struct_from_value(**dict( + (k, getattr(self, k)) for k in 'name volume channel_list mute device'.split() )) + + def __str__(self): + return self._as_str(self.volume, fields='name mute device') + +class PulseEventInfo(PulseObject): + + def __init__(self, ev_t, facility, index): + self.t, self.facility, self.index = ev_t, facility, index + + def __str__(self): + return self._as_str(fields='t facility index'.split()) + + +class Pulse(object): + + _ctx = None + + def __init__(self, client_name=None, server=None, connect=True, threading_lock=False): + '''Connects to specified pulse server by default. + Specifying "connect=False" here prevents that, but be sure to call connect() later. + "connect=False" can also be used here to + have control over options passed to connect() method. + "threading_lock" option (either bool or lock instance) can be used to wrap + non-threadsafe eventloop polling (can only be done from one thread at a time) + into a mutex lock, and should only be needed if same-instance methods + will/should/might be called from different threads at the same time.''' + self.name = client_name or 'pulsectl' + self.server, self.connected = server, None + self._ret = self._ctx = self._loop = self._api = None + self._actions, self._action_ids = dict(),\ + it.chain.from_iterable(map(range, it.repeat(2**30))) + self.init() + if threading_lock: + if threading_lock is True: + import threading + threading_lock = threading.Lock() + self._loop_lock = threading_lock + if connect: + try: self.connect(autospawn=True) + except PulseError: + self.close() + raise + + def init(self): + self._pa_state_cb = c.PA_STATE_CB_T(self._pulse_state_cb) + self._pa_subscribe_cb = c.PA_SUBSCRIBE_CB_T(self._pulse_subscribe_cb) + + self._loop, self._loop_lock = c.pa.mainloop_new(), FakeLock() + self._loop_running = self._loop_closed = False + self._api = c.pa.mainloop_get_api(self._loop) + self._ret = c.pa.return_value() + + self._ctx_init() + self.event_types = sorted(PulseEventTypeEnum._values.values()) + self.event_facilities = sorted(PulseEventFacilityEnum._values.values()) + self.event_masks = sorted(PulseEventMaskEnum._values.values()) + self.event_callback = None + + chan_names = dict() + for n in range(256): + name = c.pa.channel_position_to_string(n) + if name is None: break + chan_names[n] = name + self.channel_list_enum = Enum('channel_pos', chan_names) + + def _ctx_init(self): + if self._ctx: + with self._loop_lock: + self.disconnect() + c.pa.context_unref(self._ctx) + self._ctx = c.pa.context_new(self._api, self.name) + c.pa.context_set_state_callback(self._ctx, self._pa_state_cb, None) + c.pa.context_set_subscribe_callback(self._ctx, self._pa_subscribe_cb, None) + + def connect(self, autospawn=False, wait=False, timeout=None): + '''Connect to pulseaudio server. + "autospawn" option will start new pulse daemon, if necessary. + Specifying "wait" option will make function block until pulseaudio server appears. + "timeout" (in seconds) will raise PulseError if connection not established within it.''' + if self._loop_closed: + raise PulseError('Eventloop object was already' + ' destroyed and cannot be reused from this instance.') + if self.connected is not None: self._ctx_init() + flags, self.connected = 0, None + if not autospawn: flags |= c.PA_CONTEXT_NOAUTOSPAWN + if wait: flags |= c.PA_CONTEXT_NOFAIL + try: c.pa.context_connect(self._ctx, self.server, flags, None) + except c.pa.CallError: self.connected = False + if not timeout: # simplier process + while self.connected is None: self._pulse_iterate() + else: + self._loop_stop, delta, ts_deadline = True, 1, c.mono_time() + timeout + while self.connected is None: + delta = ts_deadline - c.mono_time() + self._pulse_poll(delta) + if delta <= 0: break + self._loop_stop = False + if not self.connected: + c.pa.context_disconnect(self._ctx) + while self.connected is not False: self._pulse_iterate() + raise PulseError('Timed-out connecting to pulseaudio server [{:,.1f}s]'.format(timeout)) + if self.connected is False: raise PulseError('Failed to connect to pulseaudio server') + + def disconnect(self): + if not self._ctx or not self.connected: return + c.pa.context_disconnect(self._ctx) + + def close(self): + if not self._loop: return + if self._loop_running: # called from another thread + self._loop_closed = True + c.pa.mainloop_quit(self._loop, 0) + return # presumably will be closed in a thread that's running it + with self._loop_lock: + try: + self.disconnect() + c.pa.context_unref(self._ctx) + c.pa.mainloop_free(self._loop) + finally: self._ctx = self._loop = None + + def __enter__(self): return self + def __exit__(self, err_t, err, err_tb): self.close() + + + def _pulse_state_cb(self, ctx, userdata): + state = c.pa.context_get_state(ctx) + if state >= c.PA_CONTEXT_READY: + if state == c.PA_CONTEXT_READY: self.connected = True + elif state in [c.PA_CONTEXT_FAILED, c.PA_CONTEXT_TERMINATED]: + self.connected, self._loop_stop = False, True + + def _pulse_subscribe_cb(self, ctx, ev, idx, userdata): + if not self.event_callback: return + n = ev & c.PA_SUBSCRIPTION_EVENT_FACILITY_MASK + ev_fac = PulseEventFacilityEnum._c_val(n, 'ev.facility.{}'.format(n)) + n = ev & c.PA_SUBSCRIPTION_EVENT_TYPE_MASK + ev_t = PulseEventTypeEnum._c_val(n, 'ev.type.{}'.format(n)) + try: self.event_callback(PulseEventInfo(ev_t, ev_fac, idx)) + except PulseLoopStop: self._loop_stop = True + + def _pulse_poll_cb(self, func, func_err, ufds, nfds, timeout, userdata): + fd_list = list(ufds[n] for n in range(nfds)) + try: nfds = func(fd_list, timeout / 1000.0) + except Exception as err: + func_err(*sys.exc_info()) + return -1 + return nfds + + @contextmanager + def _pulse_loop(self): + with self._loop_lock: + if not self._loop: return + if self._loop_running: + raise PulseError( + 'Running blocking pulse operations from pulse eventloop callbacks' + ' or other threads while loop is running is not supported by this python module.' + ' Supporting this would require threads or proper asyncio/twisted-like async code.' + ' Workaround can be to stop the loop' + ' (raise PulseLoopStop in callback or event_loop_stop() from another thread),' + ' doing whatever pulse calls synchronously and then resuming event_listen() loop.' ) + self._loop_running, self._loop_stop = True, False + try: yield self._loop + finally: + self._loop_running = False + if self._loop_closed: self.close() # to free() after stopping it + + def _pulse_run(self): + with self._pulse_loop() as loop: c.pa.mainloop_run(loop, self._ret) + + def _pulse_iterate(self, block=True): + with self._pulse_loop() as loop: c.pa.mainloop_iterate(loop, int(block), self._ret) + + @contextmanager + def _pulse_op_cb(self, raw=False): + act_id = next(self._action_ids) + self._actions[act_id] = None + try: + cb = lambda s=True,k=act_id: self._actions.update({k: bool(s)}) + if not raw: cb = c.PA_CONTEXT_SUCCESS_CB_T(lambda ctx,s,d,cb=cb: cb(s)) + yield cb + while self.connected and self._actions[act_id] is None: self._pulse_iterate() + if not self._actions[act_id]: raise PulseOperationFailed(act_id) + finally: self._actions.pop(act_id, None) + + def _pulse_poll(self, timeout=None): + '''timeout should be in seconds (float), + 0 for non-blocking poll and None (default) for no timeout.''' + with self._pulse_loop() as loop: + ts = c.mono_time() + ts_deadline = timeout and (ts + timeout) + while True: + delay = max(0, int((ts_deadline - ts) * 1000000)) if ts_deadline else -1 + c.pa.mainloop_prepare(loop, delay) # delay in us + c.pa.mainloop_poll(loop) + if self._loop_closed: break # interrupted by close() or such + c.pa.mainloop_dispatch(loop) + if self._loop_stop: break + ts = c.mono_time() + if ts_deadline and ts >= ts_deadline: break + + + def _pulse_info_cb(self, info_cls, data_list, done_cb, ctx, info, eof, userdata): + # No idea where callbacks with "userdata != NULL" come from, + # but "info" pointer in them is always invalid, so they are discarded here. + # Looks like some kind of mixup or corruption in libpulse memory? + # See also: https://github.com/mk-fg/python-pulse-control/issues/35 + if userdata is not None: return + # Empty result list and conn issues are checked elsewhere. + # Errors here are non-descriptive (errno), so should not be useful anyway. + # if eof < 0: done_cb(s=False) + if eof: done_cb() + else: data_list.append(info_cls(info[0])) + + def _pulse_get_list(cb_t, pulse_func, info_cls, singleton=False, index_arg=True): + def _wrapper_method(self, index=None): + data = list() + with self._pulse_op_cb(raw=True) as cb: + cb = cb_t( + ft.partial(self._pulse_info_cb, info_cls, data, cb) if not singleton else + lambda ctx, info, userdata, cb=cb: data.append(info_cls(info[0])) or cb() ) + pa_op = pulse_func( self._ctx, + *([index, cb, None] if index is not None else [cb, None]) ) + c.pa.operation_unref(pa_op) + data = data or list() + if index is not None or singleton: + if not data: raise PulseIndexError(index) + data, = data + return data + return wrapper_with_sig_info( None, _wrapper_method, + not (pulse_func.__name__.endswith('_list') or singleton or not index_arg) ) + + get_sink_by_name = _pulse_get_list( + c.PA_SINK_INFO_CB_T, + c.pa.context_get_sink_info_by_name, PulseSinkInfo ) + get_source_by_name = _pulse_get_list( + c.PA_SOURCE_INFO_CB_T, + c.pa.context_get_source_info_by_name, PulseSourceInfo ) + get_card_by_name = _pulse_get_list( + c.PA_CARD_INFO_CB_T, + c.pa.context_get_card_info_by_name, PulseCardInfo ) + + sink_input_list = _pulse_get_list( + c.PA_SINK_INPUT_INFO_CB_T, + c.pa.context_get_sink_input_info_list, PulseSinkInputInfo ) + sink_input_info = _pulse_get_list( + c.PA_SINK_INPUT_INFO_CB_T, + c.pa.context_get_sink_input_info, PulseSinkInputInfo ) + source_output_list = _pulse_get_list( + c.PA_SOURCE_OUTPUT_INFO_CB_T, + c.pa.context_get_source_output_info_list, PulseSourceOutputInfo ) + source_output_info = _pulse_get_list( + c.PA_SOURCE_OUTPUT_INFO_CB_T, + c.pa.context_get_source_output_info, PulseSourceOutputInfo ) + + sink_list = _pulse_get_list( + c.PA_SINK_INFO_CB_T, c.pa.context_get_sink_info_list, PulseSinkInfo ) + sink_info = _pulse_get_list( + c.PA_SINK_INFO_CB_T, c.pa.context_get_sink_info_by_index, PulseSinkInfo ) + source_list = _pulse_get_list( + c.PA_SOURCE_INFO_CB_T, c.pa.context_get_source_info_list, PulseSourceInfo ) + source_info = _pulse_get_list( + c.PA_SOURCE_INFO_CB_T, c.pa.context_get_source_info_by_index, PulseSourceInfo ) + card_list = _pulse_get_list( + c.PA_CARD_INFO_CB_T, c.pa.context_get_card_info_list, PulseCardInfo ) + card_info = _pulse_get_list( + c.PA_CARD_INFO_CB_T, c.pa.context_get_card_info_by_index, PulseCardInfo ) + client_list = _pulse_get_list( + c.PA_CLIENT_INFO_CB_T, c.pa.context_get_client_info_list, PulseClientInfo ) + client_info = _pulse_get_list( + c.PA_CLIENT_INFO_CB_T, c.pa.context_get_client_info, PulseClientInfo ) + server_info = _pulse_get_list( + c.PA_SERVER_INFO_CB_T, c.pa.context_get_server_info, PulseServerInfo, singleton=True ) + module_info = _pulse_get_list( + c.PA_MODULE_INFO_CB_T, c.pa.context_get_module_info, PulseModuleInfo ) + module_list = _pulse_get_list( + c.PA_MODULE_INFO_CB_T, c.pa.context_get_module_info_list, PulseModuleInfo ) + + + def _pulse_method_call(pulse_op, func=None, index_arg=True): + '''Creates following synchronous wrapper for async pa_operation callable: + wrapper(index, ...) -> pulse_op(index, [*]args_func(...)) + index_arg=False: wrapper(...) -> pulse_op([*]args_func(...))''' + def _wrapper(self, *args, **kws): + if index_arg: + if 'index' in kws: index = kws.pop('index') + else: index, args = args[0], args[1:] + pulse_args = func(*args, **kws) if func else list() + if not is_list(pulse_args): pulse_args = [pulse_args] + if index_arg: pulse_args = [index] + list(pulse_args) + with self._pulse_op_cb() as cb: + try: pulse_op(self._ctx, *(list(pulse_args) + [cb, None])) + except c.ArgumentError as err: raise TypeError(err.args) + except c.pa.CallError as err: raise PulseOperationInvalid(err.args[-1]) + return wrapper_with_sig_info(func, _wrapper, index_arg) + + card_profile_set_by_index = _pulse_method_call( + c.pa.context_set_card_profile_by_index, lambda profile_name: profile_name ) + + sink_default_set = _pulse_method_call( + c.pa.context_set_default_sink, index_arg=False, + func=lambda sink: sink.name if isinstance(sink, PulseSinkInfo) else sink ) + source_default_set = _pulse_method_call( + c.pa.context_set_default_source, index_arg=False, + func=lambda source: source.name if isinstance(source, PulseSourceInfo) else source ) + + sink_input_mute = _pulse_method_call( + c.pa.context_set_sink_input_mute, lambda mute=True: mute ) + sink_input_move = _pulse_method_call( + c.pa.context_move_sink_input_by_index, lambda sink_index: sink_index ) + sink_mute = _pulse_method_call( + c.pa.context_set_sink_mute_by_index, lambda mute=True: mute ) + sink_input_volume_set = _pulse_method_call( + c.pa.context_set_sink_input_volume, lambda vol: vol.to_struct() ) + sink_volume_set = _pulse_method_call( + c.pa.context_set_sink_volume_by_index, lambda vol: vol.to_struct() ) + sink_suspend = _pulse_method_call( + c.pa.context_suspend_sink_by_index, lambda suspend=True: suspend ) + sink_port_set = _pulse_method_call( + c.pa.context_set_sink_port_by_index, + lambda port: port.name if isinstance(port, PulsePortInfo) else port ) + + source_output_mute = _pulse_method_call( + c.pa.context_set_source_output_mute, lambda mute=True: mute ) + source_output_move = _pulse_method_call( + c.pa.context_move_source_output_by_index, lambda sink_index: sink_index ) + source_mute = _pulse_method_call( + c.pa.context_set_source_mute_by_index, lambda mute=True: mute ) + source_output_volume_set = _pulse_method_call( + c.pa.context_set_source_output_volume, lambda vol: vol.to_struct() ) + source_volume_set = _pulse_method_call( + c.pa.context_set_source_volume_by_index, lambda vol: vol.to_struct() ) + source_suspend = _pulse_method_call( + c.pa.context_suspend_source_by_index, lambda suspend=True: suspend ) + source_port_set = _pulse_method_call( + c.pa.context_set_source_port_by_index, + lambda port: port.name if isinstance(port, PulsePortInfo) else port ) + + + def module_load(self, name, args=''): + if is_list(args): args = ' '.join(args) + name, args = map(c.force_bytes, [name, args]) + data = list() + with self._pulse_op_cb(raw=True) as cb: + cb = c.PA_CONTEXT_INDEX_CB_T( + lambda ctx, index, userdata, cb=cb: data.append(index) or cb() ) + try: c.pa.context_load_module(self._ctx, name, args, cb, None) + except c.pa.CallError as err: raise PulseOperationInvalid(err.args[-1]) + index, = data + if index == c.PA_INVALID: + raise PulseError('Failed to load module: {} {}'.format(name, args)) + return index + + module_unload = _pulse_method_call(c.pa.context_unload_module, None) + + + def stream_restore_test(self): + 'Returns module-stream-restore version int (e.g. 1) or None if it is unavailable.' + data = list() + with self._pulse_op_cb(raw=True) as cb: + cb = c.PA_EXT_STREAM_RESTORE_TEST_CB_T( + lambda ctx, version, userdata, cb=cb: data.append(version) or cb() ) + try: c.pa.ext_stream_restore_test(self._ctx, cb, None) + except c.pa.CallError as err: raise PulseOperationInvalid(err.args[-1]) + version, = data + return version if version != c.PA_INVALID else None + + stream_restore_read = _pulse_get_list( + c.PA_EXT_STREAM_RESTORE_READ_CB_T, + c.pa.ext_stream_restore_read, PulseExtStreamRestoreInfo, index_arg=False ) + stream_restore_list = stream_restore_read # for consistency with other *_list methods + + @ft.partial(_pulse_method_call, c.pa.ext_stream_restore_write, index_arg=False) + def stream_restore_write( obj_name_or_list, + mode='merge', apply_immediately=False, **obj_kws ): + '''Update module-stream-restore db entry for specified name. + Can be passed PulseExtStreamRestoreInfo object or list of them as argument, + or name string there and object init keywords (e.g. volume, mute, channel_list, etc). + "mode" is PulseUpdateEnum value of + 'merge' (default), 'replace' or 'set' (replaces ALL entries!!!).''' + mode = PulseUpdateEnum[mode]._c_val + if is_str(obj_name_or_list): + obj_name_or_list = PulseExtStreamRestoreInfo(obj_name_or_list, **obj_kws) + if isinstance(obj_name_or_list, PulseExtStreamRestoreInfo): + obj_name_or_list = [obj_name_or_list] + # obj_array is an array of structs, laid out contiguously in memory, not pointers + obj_array = (c.PA_EXT_STREAM_RESTORE_INFO * len(obj_name_or_list))() + for n, obj in enumerate(obj_name_or_list): + obj_struct, dst_struct = obj.to_struct(), obj_array[n] + for k,t in obj_struct._fields_: setattr(dst_struct, k, getattr(obj_struct, k)) + return mode, obj_array, len(obj_array), int(bool(apply_immediately)) + + @ft.partial(_pulse_method_call, c.pa.ext_stream_restore_delete, index_arg=False) + def stream_restore_delete(obj_name_or_list): + '''Can be passed string name, + PulseExtStreamRestoreInfo object or a list of any of these.''' + if is_str(obj_name_or_list, PulseExtStreamRestoreInfo): + obj_name_or_list = [obj_name_or_list] + name_list = list((obj.name if isinstance( obj, + PulseExtStreamRestoreInfo ) else obj) for obj in obj_name_or_list) + name_struct = (c.c_char_p * len(name_list))() + name_struct[:] = list(map(c.force_bytes, name_list)) + return [name_struct] + + + def default_set(self, obj): + 'Set passed sink or source to be used as default one by pulseaudio server.' + assert_pulse_object(obj) + method = { + PulseSinkInfo: self.sink_default_set, + PulseSourceInfo: self.source_default_set }.get(type(obj)) + if not method: raise NotImplementedError(type(obj)) + method(obj) + + def mute(self, obj, mute=True): + assert_pulse_object(obj) + method = { + PulseSinkInfo: self.sink_mute, + PulseSinkInputInfo: self.sink_input_mute, + PulseSourceInfo: self.source_mute, + PulseSourceOutputInfo: self.source_output_mute }.get(type(obj)) + if not method: raise NotImplementedError(type(obj)) + method(obj.index, mute) + obj.mute = mute + + def port_set(self, obj, port): + assert_pulse_object(obj) + method = { + PulseSinkInfo: self.sink_port_set, + PulseSourceInfo: self.source_port_set }.get(type(obj)) + if not method: raise NotImplementedError(type(obj)) + method(obj.index, port) + obj.port_active = port + + def card_profile_set(self, card, profile): + assert_pulse_object(card) + if is_str(profile): + profile_dict = dict((p.name, p) for p in card.profile_list) + if profile not in profile_dict: + raise PulseIndexError( 'Card does not have' + ' profile with specified name: {!r}'.format(profile) ) + profile = profile_dict[profile] + self.card_profile_set_by_index(card.index, profile.name) + card.profile_active = profile + + def volume_set(self, obj, vol): + assert_pulse_object(obj) + method = { + PulseSinkInfo: self.sink_volume_set, + PulseSinkInputInfo: self.sink_input_volume_set, + PulseSourceInfo: self.source_volume_set, + PulseSourceOutputInfo: self.source_output_volume_set }.get(type(obj)) + if not method: raise NotImplementedError(type(obj)) + method(obj.index, vol) + obj.volume = vol + + def volume_set_all_chans(self, obj, vol): + assert_pulse_object(obj) + obj.volume.value_flat = vol + self.volume_set(obj, obj.volume) + + def volume_change_all_chans(self, obj, inc): + assert_pulse_object(obj) + obj.volume.values = [max(0, v + inc) for v in obj.volume.values] + self.volume_set(obj, obj.volume) + + def volume_get_all_chans(self, obj): + # Purpose of this func can be a bit confusing, being here next to set/change ones + '''Get "flat" volume float value for info-object as a mean of all channel values. + Note that this DOES NOT query any kind of updated values from libpulse, + and simply returns value(s) stored in passed object, i.e. same ones for same object.''' + assert_pulse_object(obj) + return obj.volume.value_flat + + + def event_mask_set(self, *masks): + mask = 0 + for m in masks: mask |= PulseEventMaskEnum[m]._c_val + with self._pulse_op_cb() as cb: + c.pa.context_subscribe(self._ctx, mask, cb, None) + + def event_callback_set(self, func): + '''Call event_listen() to start receiving these, + and be sure to raise PulseLoopStop in a callback to stop the loop. + Callback should accept single argument - PulseEventInfo instance. + Passing None will disable the thing.''' + self.event_callback = func + + def event_listen(self, timeout=None, raise_on_disconnect=True): + '''Does not return until PulseLoopStop + gets raised in event callback or timeout passes. + timeout should be in seconds (float), + 0 for non-blocking poll and None (default) for no timeout. + raise_on_disconnect causes PulseDisconnected exceptions by default. + Do not run any pulse operations from these callbacks.''' + assert self.event_callback + try: self._pulse_poll(timeout) + except c.pa.CallError: pass # e.g. from mainloop_dispatch() on disconnect + if raise_on_disconnect and not self.connected: raise PulseDisconnected() + + def event_listen_stop(self): + '''Stop event_listen() loop from e.g. another thread. + Does nothing if libpulse poll is not running yet, so might be racey with + event_listen() - be sure to call it in a loop until event_listen returns or something.''' + self._loop_stop = True + c.pa.mainloop_wakeup(self._loop) + + + def set_poll_func(self, func, func_err_handler=None): + '''Can be used to integrate pulse client into existing eventloop. + Function will be passed a list of pollfd structs and timeout value (seconds, float), + which it is responsible to use and modify (set poll flags) accordingly, + returning int value >= 0 with number of fds that had any new events within timeout. + func_err_handler defaults to traceback.print_exception(), + and will be called on any exceptions from callback (to e.g. log these), + returning poll error code (-1) to libpulse after that.''' + if not func_err_handler: func_err_handler = traceback.print_exception + self._pa_poll_cb = c.PA_POLL_FUNC_T(ft.partial(self._pulse_poll_cb, func, func_err_handler)) + c.pa.mainloop_set_poll_func(self._loop, self._pa_poll_cb, None) + + + def get_peak_sample(self, source, timeout, stream_idx=None): + '''Returns peak (max) value in 0-1.0 range for samples in source/stream within timespan. + "source" can be either int index of pulseaudio source + (i.e. source.index), its name (source.name), or None to use default source. + Resulting value is what pulseaudio returns as + PA_SAMPLE_FLOAT32NE float after "timeout" seconds. + If specified source does not exist, 0 should be returned after timeout. + This can be used to detect if there's any sound + on the microphone or any sound played through a sink via its monitor_source index, + or same for any specific stream connected to these (if "stream_idx" is passed). + Sample stream masquerades as + application.id=org.PulseAudio.pavucontrol to avoid being listed in various mixer apps. + Example - get peak for specific sink input "si" for 0.8 seconds: + pulse.get_peak_sample(pulse.sink_info(si.sink).monitor_source, 0.8, si.index)''' + samples, proplist = [0], c.pa.proplist_from_string('application.id=org.PulseAudio.pavucontrol') + ss = c.PA_SAMPLE_SPEC(format=c.PA_SAMPLE_FLOAT32NE, rate=25, channels=1) + s = c.pa.stream_new_with_proplist(self._ctx, 'peak detect', c.byref(ss), None, proplist) + c.pa.proplist_free(proplist) + + @c.PA_STREAM_REQUEST_CB_T + def read_cb(s, bs, userdata): + buff, bs = c.c_void_p(), c.c_int(bs) + c.pa.stream_peek(s, buff, c.byref(bs)) + try: + if not buff or bs.value < 4: return + # This assumes that native byte order for floats is BE, same as pavucontrol + samples[0] = max(samples[0], c.cast(buff, c.POINTER(c.c_float))[0]) + finally: + # stream_drop() flushes buffered data (incl. buff=NULL "hole" data) + # stream.h: "should not be called if the buffer is empty" + if bs.value: c.pa.stream_drop(s) + + if stream_idx is not None: c.pa.stream_set_monitor_stream(s, stream_idx) + c.pa.stream_set_read_callback(s, read_cb, None) + if source is not None: source = unicode(source).encode('utf-8') + try: + c.pa.stream_connect_record( s, source, + c.PA_BUFFER_ATTR(fragsize=4, maxlength=2**32-1), + c.PA_STREAM_DONT_MOVE | c.PA_STREAM_PEAK_DETECT | + c.PA_STREAM_ADJUST_LATENCY | c.PA_STREAM_DONT_INHIBIT_AUTO_SUSPEND ) + except c.pa.CallError: + c.pa.stream_unref(s) + raise + + try: self._pulse_poll(timeout) + finally: + try: c.pa.stream_disconnect(s) + except c.pa.CallError: pass # stream was removed + + c.pa.stream_unref(s) + return min(1.0, samples[0]) + + def play_sample(self, name, sink=None, volume=1.0, proplist_str=None): + '''Play specified sound sample, + with an optional sink object/name/index, volume and proplist string parameters. + Sample must be stored on the server in advance, see e.g. "pacmd list-samples". + See also libcanberra for an easy XDG theme sample loading, storage and playback API.''' + if isinstance(sink, PulseSinkInfo): sink = sink.index + sink = str(sink) if sink is not None else None + proplist = c.pa.proplist_from_string(proplist_str) if proplist_str else None + volume = int(round(volume*c.PA_VOLUME_NORM)) + with self._pulse_op_cb() as cb: + try: + if not proplist: + c.pa.context_play_sample(self._ctx, name, sink, volume, cb, None) + else: + c.pa.context_play_sample_with_proplist( + self._ctx, name, sink, volume, proplist, cb, None ) + except c.pa.CallError as err: raise PulseOperationInvalid(err.args[-1]) + + +def connect_to_cli(server=None, as_file=True, socket_timeout=1.0, attempts=5, retry_delay=0.3): + '''Returns connected CLI interface socket (as file object, unless as_file=False), + where one can send same commands (as lines) as to "pacmd" tool + or pulseaudio startup files (e.g. "default.pa"). + "server" option can be specified to use non-standard unix socket path + (when passed absolute path string) or remote tcp socket, + when passed remote host address (to use default port) or (host, port) tuple. + Be sure to adjust "socket_timeout" option for tcp sockets over laggy internet. + Returned file object has line-buffered output, + so there should be no need to use flush() after every command. + Be sure to read from the socket line-by-line until + "### EOF" or timeout for commands that have output (e.g. "dump\\n"). + If default server socket is used (i.e. not specified), + server pid will be signaled to load module-cli between connection attempts. + Completely separate protocol from the regular API, as wrapped by libpulse. + PulseError is raised on any failure.''' + import socket, errno, signal, time + s, n = None, attempts if attempts > 0 else None + try: + pid_path, sock_af, sock_t = None, socket.AF_UNIX, socket.SOCK_STREAM + if not server: server, pid_path = map(c.pa.runtime_path, ['cli', 'pid']) + else: + if not is_list(server): + server = c.force_str(server) + if not server.startswith('/'): server = server, 4712 # default port + if is_list(server): + try: + addrinfo = socket.getaddrinfo( + server[0], server[1], 0, sock_t, socket.IPPROTO_TCP ) + if not addrinfo: raise socket.gaierror('No addrinfo for socket: {}'.format(server)) + except (socket.gaierror, socket.error) as err: + raise PulseError( 'Failed to resolve socket parameters' + ' (address, family) via getaddrinfo: {!r} - {} {}'.format(server, type(err), err) ) + sock_af, sock_t, _, _, server = addrinfo[0] + + s = socket.socket(sock_af, sock_t) + s.settimeout(socket_timeout) + while True: + ts = c.mono_time() + try: s.connect(server) + except socket.error as err: + if err.errno not in [errno.ECONNREFUSED, errno.ENOENT, errno.ECONNABORTED]: raise + else: break + if n: + n -= 1 + if n <= 0: raise PulseError('Number of connection attempts ({}) exceeded'.format(attempts)) + if pid_path: + with open(pid_path) as src: os.kill(int(src.read().strip()), signal.SIGUSR2) + time.sleep(max(0, retry_delay - (c.mono_time() - ts))) + + if as_file: res = s.makefile('rw', 1) + else: res, s = s, None # to avoid closing this socket + return res + + except Exception as err: # CallError, socket.error, IOError (pidfile), OSError (os.kill) + raise PulseError( 'Failed to connect to pulse' + ' cli socket {!r}: {} {}'.format(server, type(err), err) ) + + finally: + if s: s.close() diff --git a/src/utils/settings/settings.py b/src/utils/settings/settings.py index f684373..d7bd8c8 100644 --- a/src/utils/settings/settings.py +++ b/src/utils/settings/settings.py @@ -28,7 +28,6 @@ class Settings(StartCheckMixin, Singleton): self._PLUGINS_PATH = f"{self._HOME_CONFIG_PATH}/plugins" self._DEFAULT_ICONS = f"{self._HOME_CONFIG_PATH}/icons" self._CONFIG_FILE = f"{self._HOME_CONFIG_PATH}/settings.json" - self._GLADE_FILE = f"{self._HOME_CONFIG_PATH}/Main_Window.glade" self._CSS_FILE = f"{self._HOME_CONFIG_PATH}/stylesheet.css" self._KEY_BINDINGS_FILE = f"{self._HOME_CONFIG_PATH}/key-bindings.json" self._PID_FILE = f"{self._HOME_CONFIG_PATH}/{app_name.lower()}.pid" @@ -36,23 +35,6 @@ class Settings(StartCheckMixin, Singleton): self._CONTEXT_MENU = f"{self._HOME_CONFIG_PATH}/contexct_menu.json" self._WINDOW_ICON = f"{self._DEFAULT_ICONS}/{app_name.lower()}.png" - # self._USR_CONFIG_FILE = f"{self._USR_PATH}/settings.json" - # self._PLUGINS_PATH = f"plugins" - # self._CONFIG_FILE = f"settings.json" - # self._GLADE_FILE = f"Main_Window.glade" - # self._CSS_FILE = f"stylesheet.css" - # self._KEY_BINDINGS_FILE = f"key-bindings.json" - # self._PID_FILE = f"{app_name.lower()}.pid" - # self._WINDOW_ICON = f"{app_name.lower()}.png" - # self._UI_WIDEGTS_PATH = f"ui_widgets" - # self._CONTEXT_MENU = f"contexct_menu.json" - # self._DEFAULT_ICONS = f"icons" - - - # with zipfile.ZipFile("files.zip", mode="r", allowZip64=True) as zf: - # with io.TextIOWrapper(zf.open("text1.txt"), encoding="utf-8") as f: - - if not os.path.exists(self._HOME_CONFIG_PATH): os.mkdir(self._HOME_CONFIG_PATH) if not os.path.exists(self._PLUGINS_PATH): @@ -69,10 +51,6 @@ class Settings(StartCheckMixin, Singleton): self._DEFAULT_ICONS = f"{self._USR_PATH}/icons" if not os.path.exists(self._DEFAULT_ICONS): raise MissingConfigError("Unable to find the application icons directory.") - if not os.path.exists(self._GLADE_FILE): - self._GLADE_FILE = f"{self._USR_PATH}/Main_Window.glade" - if not os.path.exists(self._GLADE_FILE): - raise MissingConfigError("Unable to find the application Glade file.") if not os.path.exists(self._KEY_BINDINGS_FILE): self._KEY_BINDINGS_FILE = f"{self._USR_PATH}/key-bindings.json" if not os.path.exists(self._KEY_BINDINGS_FILE): @@ -154,7 +132,6 @@ class Settings(StartCheckMixin, Singleton): def get_main_window_min_height(self) -> any: return self._main_window_mh def get_builder(self) -> any: return self._builder def get_paint_bg_color(self) -> any: return self.PAINT_BG_COLOR - def get_glade_file(self) -> str: return self._GLADE_FILE def get_ui_widgets_path(self) -> str: return self._UI_WIDEGTS_PATH def get_context_menu_data(self) -> str: return self._context_menu_data diff --git a/src/utils/settings/start_check_mixin.py b/src/utils/settings/start_check_mixin.py index 7fba503..688da36 100644 --- a/src/utils/settings/start_check_mixin.py +++ b/src/utils/settings/start_check_mixin.py @@ -41,6 +41,7 @@ class StartCheckMixin: def _write_new_pid(self): pid = os.getpid() self._write_pid(pid) + print(f"{app_name} PID: {pid}") def _clean_pid(self): os.unlink(self._PID_FILE) diff --git a/user_config/bin/ b/user_config/bin/pulstar similarity index 62% rename from user_config/bin/ rename to user_config/bin/pulstar index 7a3e523..c651cfd 100755 --- a/user_config/bin/ +++ b/user_config/bin/pulstar @@ -17,13 +17,7 @@ function main() { path="${1}" fi - # NOTE: Remove if you want to pass file(s) besides directories... - if [ ! -d "${path}" ]; then - echo ": Path given not a directory..." - exit 1 - fi - cd "/opt/" - python /opt/.zip "$@" + python /opt/pulstar.zip } main "$@"; diff --git a/user_config/usr/applications/.desktop b/user_config/usr/applications/.desktop deleted file mode 100755 index d459bfb..0000000 --- a/user_config/usr/applications/.desktop +++ /dev/null @@ -1,11 +0,0 @@ -[Desktop Entry] -Name= -GenericName= -Comment= -Exec=/bin/ %F -Icon=/usr/share//icons/.png -Type=Application -StartupNotify=true -Categories=System;FileTools;Utility;Core;GTK;FileManager; -MimeType= -Terminal=false diff --git a/user_config/usr/applications/pulstar.desktop b/user_config/usr/applications/pulstar.desktop new file mode 100755 index 0000000..9207f5d --- /dev/null +++ b/user_config/usr/applications/pulstar.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Name=Pulstar +GenericName=Volume Controlls +Comment=Pulstar is a Python + Gtk app to control the volume levels +Exec=/bin/pulstar +Icon=/usr/share/pulstar/icons/pulstar.png +Type=Application +StartupNotify=true +Categories=AudioVideo;Audio;Mixer;GTK;Settings;X-XFCE-SettingsDialog;X-XFCE-HardwareSettings; +Terminal=false diff --git a/user_config/usr/share/app_name/Main_Window.glade b/user_config/usr/share/app_name/Main_Window.glade deleted file mode 100644 index c1a1964..0000000 --- a/user_config/usr/share/app_name/Main_Window.glade +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - True - False - vertical - - - True - False - Loaded Me From Glade! - - - False - True - 0 - - - - - - - - - - diff --git a/user_config/usr/share/app_name/icons/app_name-64x64.png b/user_config/usr/share/app_name/icons/app_name-64x64.png deleted file mode 100644 index 6105709..0000000 Binary files a/user_config/usr/share/app_name/icons/app_name-64x64.png and /dev/null differ diff --git a/user_config/usr/share/app_name/icons/app_name.png b/user_config/usr/share/app_name/icons/app_name.png deleted file mode 100644 index c40fb0f..0000000 Binary files a/user_config/usr/share/app_name/icons/app_name.png and /dev/null differ diff --git a/user_config/usr/share/app_name/icons/archive.png b/user_config/usr/share/app_name/icons/archive.png deleted file mode 100644 index 7943e4e..0000000 Binary files a/user_config/usr/share/app_name/icons/archive.png and /dev/null differ diff --git a/user_config/usr/share/app_name/icons/dir.png b/user_config/usr/share/app_name/icons/dir.png deleted file mode 100644 index a9b5e9f..0000000 Binary files a/user_config/usr/share/app_name/icons/dir.png and /dev/null differ diff --git a/user_config/usr/share/app_name/icons/doc.png b/user_config/usr/share/app_name/icons/doc.png deleted file mode 100644 index f838826..0000000 Binary files a/user_config/usr/share/app_name/icons/doc.png and /dev/null differ diff --git a/user_config/usr/share/app_name/icons/image.png b/user_config/usr/share/app_name/icons/image.png deleted file mode 100644 index 46f1ae6..0000000 Binary files a/user_config/usr/share/app_name/icons/image.png and /dev/null differ diff --git a/user_config/usr/share/app_name/icons/pdf.png b/user_config/usr/share/app_name/icons/pdf.png deleted file mode 100644 index 9f40122..0000000 Binary files a/user_config/usr/share/app_name/icons/pdf.png and /dev/null differ diff --git a/user_config/usr/share/app_name/icons/presentation.png b/user_config/usr/share/app_name/icons/presentation.png deleted file mode 100644 index 3a339af..0000000 Binary files a/user_config/usr/share/app_name/icons/presentation.png and /dev/null differ diff --git a/user_config/usr/share/app_name/icons/spreadsheet.png b/user_config/usr/share/app_name/icons/spreadsheet.png deleted file mode 100644 index 710efa6..0000000 Binary files a/user_config/usr/share/app_name/icons/spreadsheet.png and /dev/null differ diff --git a/user_config/usr/share/app_name/icons/text.png b/user_config/usr/share/app_name/icons/text.png deleted file mode 100644 index 2546fcd..0000000 Binary files a/user_config/usr/share/app_name/icons/text.png and /dev/null differ diff --git a/user_config/usr/share/app_name/icons/trash.png b/user_config/usr/share/app_name/icons/trash.png deleted file mode 100644 index c6514b9..0000000 Binary files a/user_config/usr/share/app_name/icons/trash.png and /dev/null differ diff --git a/user_config/usr/share/app_name/icons/web.png b/user_config/usr/share/app_name/icons/web.png deleted file mode 100644 index 17017ce..0000000 Binary files a/user_config/usr/share/app_name/icons/web.png and /dev/null differ diff --git a/user_config/usr/share/app_name/contexct_menu.json b/user_config/usr/share/pulstar/contexct_menu.json similarity index 100% rename from user_config/usr/share/app_name/contexct_menu.json rename to user_config/usr/share/pulstar/contexct_menu.json diff --git a/user_config/usr/share/app_name/icons/audio.png b/user_config/usr/share/pulstar/icons/audio.png similarity index 100% rename from user_config/usr/share/app_name/icons/audio.png rename to user_config/usr/share/pulstar/icons/audio.png diff --git a/user_config/usr/share/app_name/icons/bin.png b/user_config/usr/share/pulstar/icons/bin.png similarity index 100% rename from user_config/usr/share/app_name/icons/bin.png rename to user_config/usr/share/pulstar/icons/bin.png diff --git a/user_config/usr/share/pulstar/icons/pulstar-64x64.png b/user_config/usr/share/pulstar/icons/pulstar-64x64.png new file mode 100644 index 0000000..d8f9833 Binary files /dev/null and b/user_config/usr/share/pulstar/icons/pulstar-64x64.png differ diff --git a/user_config/usr/share/pulstar/icons/pulstar.png b/user_config/usr/share/pulstar/icons/pulstar.png new file mode 100644 index 0000000..9df056a Binary files /dev/null and b/user_config/usr/share/pulstar/icons/pulstar.png differ diff --git a/user_config/usr/share/app_name/icons/video.png b/user_config/usr/share/pulstar/icons/video.png similarity index 100% rename from user_config/usr/share/app_name/icons/video.png rename to user_config/usr/share/pulstar/icons/video.png diff --git a/user_config/usr/share/app_name/key-bindings.json b/user_config/usr/share/pulstar/key-bindings.json similarity index 100% rename from user_config/usr/share/app_name/key-bindings.json rename to user_config/usr/share/pulstar/key-bindings.json diff --git a/user_config/usr/share/app_name/settings.json b/user_config/usr/share/pulstar/settings.json similarity index 98% rename from user_config/usr/share/app_name/settings.json rename to user_config/usr/share/pulstar/settings.json index c11b4fd..9b0686d 100644 --- a/user_config/usr/share/app_name/settings.json +++ b/user_config/usr/share/pulstar/settings.json @@ -35,7 +35,7 @@ "error_color":"#ff0000" }, "debugging": { - "ch_log_lvl": 10, + "ch_log_lvl": 20, "fh_log_lvl": 20 } } diff --git a/user_config/usr/share/app_name/stylesheet.css b/user_config/usr/share/pulstar/stylesheet.css similarity index 100% rename from user_config/usr/share/app_name/stylesheet.css rename to user_config/usr/share/pulstar/stylesheet.css