Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## Added
- Systray toggles: a new "Systray toggles" section in the main window lets you choose, per device, which of the device's toggle settings appear as quick switches in the system tray menu
- Live settings synchronization via `SettingsChanged` D-Bus signal subscription
- `SetSystrayToggle` D-Bus method to pin/unpin a device toggle from the system tray (per-device UI preference)

## [2.4.1]

## Added
Expand Down
13 changes: 12 additions & 1 deletion docs/dbus.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ The response varies depending on the list, but it will always return a list of o
- **Response format**: JSON
- **Specs**:

The response has three sections:
The response has four sections:
- `general`: for general (cross-device) settings.
- `device`: for device-specific settings. Will be an empty object if no device is connected.
- `systray_toggles`: a list of device setting names the user pinned to the system tray menu. Empty if no device is connected.
- `settings_config`: the definition for each setting, defining type, default_value and other arguments depending on the type. See **YAML's device.settings.[section].[setting] types**.

The clients shouldn't hard-core the settings, but read them and parse them depending on the `settings_config` section.
Expand All @@ -45,6 +46,7 @@ The clients shouldn't hard-core the settings, but read them and parse them depen
"setting_b": 0,
"setting_c": 10
},
"systray_toggles": ["toggle_setting"],
"settings_config": {
"toggle_setting": {
"type": "toggle",
Expand Down Expand Up @@ -90,6 +92,15 @@ Writes the setting. Searches the setting first in the general settings and then,

Returns boolean (true: setting saved, false: setting not found / not saved)

### Method: SetSystrayToggle
- **Parameters**: name: string, enabled: boolean
- **Response format**: boolean
- **Specs**:

Pins (`enabled = true`) or unpins (`enabled = false`) a device toggle setting from the system tray quick-switch menu. This is a UI preference persisted per device; it does **not** send any command to the device.

Returns `false` if no device is connected, the setting name is unknown for the current device, or the setting is not of type `toggle`. On success, emits `SettingsChanged`.

## name.giacomofurlan.ArctisManager.Next.Status

- **Interface Path**: /name/giacomofurlan/ArctisManager/Next/Status
Expand Down
18 changes: 16 additions & 2 deletions src/linux_arctis_manager/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,15 @@ class CoreEngine:
device_status_observers: list[Callable[[dict[str, int]], None]]
device_settings_observers: list[Callable[[DeviceSettings], None]]
general_settings_observers: list[Callable[[GeneralSettings], None]]
settings_observers: list[Callable[[], None]]

def __init__(self) -> None:
self.media_mix = 100
self.chat_mix = 100
self.device_status_observers = []
self.device_settings_observers = []
self.general_settings_observers = []
self.settings_observers = []

self.general_settings = GeneralSettings.read_from_file()

Expand Down Expand Up @@ -260,7 +262,9 @@ def configure_virtual_sinks(self) -> None:
self.pa_audio_manager.sinks_setup(self.device_config.name, self.device_config.vendor_id, self.device_config.product_ids)

self.redirect_to_media_sink()


self.notify_settings_changed()

def init_device(self):
self.logger.info("Initializing device...")
if self.device_config and self.device_config.device_init:
Expand All @@ -283,7 +287,15 @@ def is_device_online(self) -> bool:
def register_status_observer(self, observer: Callable[[dict[str, int]], None]):
if observer not in self.device_status_observers:
self.device_status_observers.append(observer)


def register_settings_observer(self, observer: Callable[[], None]):
if observer not in self.settings_observers:
self.settings_observers.append(observer)

def notify_settings_changed(self):
for observer in self.settings_observers:
observer()

def on_device_status_changed(self, key: str, value: int):
if self.device_config and self.device_config.online_status and key == self.device_config.online_status.status_variable:
if self.is_device_online():
Expand Down Expand Up @@ -482,3 +494,5 @@ def teardown(self) -> None:
self.usb_device = None
self.device_config = None
self.device_status = None

self.notify_settings_changed()
73 changes: 67 additions & 6 deletions src/linux_arctis_manager/dbus_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
from dbus_next.aio.message_bus import MessageBus
from dbus_next.service import ServiceInterface, method, signal

from linux_arctis_manager.config import DeviceConfiguration, parsed_status
from linux_arctis_manager.config import (DeviceConfiguration, SettingType,
parsed_status)
from linux_arctis_manager.constants import (DBUS_BUS_NAME,
DBUS_CONFIG_INTERFACE_NAME,
DBUS_CONFIG_OBJECT_PATH,
Expand Down Expand Up @@ -81,21 +82,37 @@ def __init__(self, core: CoreEngine):
super().__init__(DBUS_SETTINGS_INTERFACE_NAME)
self.core_engine = core
self.logger = logging.getLogger('ArctisManagerDbusSettingsService')

self.last_settings = ''
self.core_engine.register_settings_observer(self._on_settings_changed)

def _on_settings_changed(self) -> None:
dumped = self.settings_to_json(
self.core_engine.general_settings,
self.core_engine.device_config,
self.core_engine.device_settings,
)

if dumped == self.last_settings:
return

self.last_settings = dumped

self.signal_settings_changed(dumped)

def settings_to_json(self, general_settings: GeneralSettings, device_config: DeviceConfiguration|None, device_settings: DeviceSettings|None) -> str:
settings = {
'general': general_settings.to_dict(),
'device': {},
'systray_toggles': [],
'settings_config': {
config.name: config.to_dict()
for config in self.core_engine.general_settings.settings_config
},
}

if device_config and device_settings:
settings.update({'device': device_settings.settings})
if device_config and device_settings:
settings.update({'device': device_settings.settings})
settings['device'] = dict(device_settings.settings)
settings['systray_toggles'] = list(device_settings.systray_toggles)
settings['settings_config'].update({
config.name: config.to_dict()
for config in list(itertools.chain.from_iterable(
Expand Down Expand Up @@ -161,7 +178,51 @@ def set_setting(self, setting: 's', value: 's') -> 'b': # type: ignore
return True

return False


def set_systray_toggle(self, name: str, enabled: bool) -> bool:
if not self.core_engine.device_config or not self.core_engine.device_settings:
self.logger.error('SetSystrayToggle: no device connected')
return False

config = next((
cfg
for section in self.core_engine.device_config.settings.values()
for cfg in section
if cfg.name == name
), None)

if not config:
self.logger.error(f'SetSystrayToggle: unknown setting "{name}" for the current device')
return False

if config.type != SettingType.TOGGLE:
self.logger.error(f'SetSystrayToggle: setting "{name}" is not a toggle')
return False

toggles = self.core_engine.device_settings.systray_toggles
mutated = False
if enabled and name not in toggles:
toggles.append(name)
mutated = True
elif not enabled and name in toggles:
toggles.remove(name)
mutated = True

if mutated:
self.core_engine.device_settings.write_to_file()
self.last_settings = self.settings_to_json(
self.core_engine.general_settings,
self.core_engine.device_config,
self.core_engine.device_settings,
)
self.signal_settings_changed(self.last_settings)

return True

@method('SetSystrayToggle')
def _dbus_set_systray_toggle(self, name: 's', enabled: 'b') -> 'b': # type: ignore
return self.set_systray_toggle(name, enabled)

@method('GetListOptions')
def get_list_options(self, list_name: 's') -> 's': # type: ignore
result = []
Expand Down
48 changes: 46 additions & 2 deletions src/linux_arctis_manager/gui/dbus_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ def __init__(self, parent: QObject|None = None):
self._status_signal_loop: asyncio.AbstractEventLoop|None = None
self._stop_status_signal_future: asyncio.Future|None = None

self._settings_signal_loop: asyncio.AbstractEventLoop|None = None
self._stop_settings_signal_future: asyncio.Future|None = None

self._status_iface: ProxyInterface|None = None

async def status_iface(self):
Expand All @@ -58,7 +61,10 @@ def start(self):

status_signal_thread = Thread(target=lambda: asyncio.run(self._register_status_dbus_signal()))
status_signal_thread.start()


settings_signal_thread = Thread(target=lambda: asyncio.run(self._register_settings_dbus_signal()))
settings_signal_thread.start()

async def _register_status_dbus_signal(self):
def callback(status: str) -> None:
self.sig_status.emit(json.loads(status) or {})
Expand All @@ -69,11 +75,27 @@ def callback(status: str) -> None:
self._stop_status_signal_future = self._status_signal_loop.create_future()
await self._stop_status_signal_future

async def _register_settings_dbus_signal(self):
def callback(settings: str) -> None:
self.sig_settings.emit(json.loads(settings) or {})

bus = await MessageBus().connect()
introspection = await bus.introspect(DBUS_BUS_NAME, DBUS_SETTINGS_OBJECT_PATH)
obj = bus.get_proxy_object(DBUS_BUS_NAME, DBUS_SETTINGS_OBJECT_PATH, introspection)
iface = obj.get_interface(DBUS_SETTINGS_INTERFACE_NAME)
iface.on_settings_changed(callback) # type: ignore

self._settings_signal_loop = asyncio.get_running_loop()
self._stop_settings_signal_future = self._settings_signal_loop.create_future()
await self._stop_settings_signal_future

def stop(self):
self.logger.info("Stopping D-Bus wrapper...")
self._stopping = True
if self._status_signal_loop and self._stop_status_signal_future:
self._status_signal_loop.call_soon_threadsafe(self._stop_status_signal_future.set_result, None)
if self._settings_signal_loop and self._stop_settings_signal_future:
self._settings_signal_loop.call_soon_threadsafe(self._stop_settings_signal_future.set_result, None)

def request_status(self) -> None:
request_thread = Thread(target=lambda: asyncio.run(self._request_status_async()))
Expand Down Expand Up @@ -148,4 +170,26 @@ async def change_setting_async(name: str, value: int|bool|str):
signature='ss',
body=[name, json.dumps(value)],
))


@staticmethod
def set_systray_toggle(name: str, enabled: bool) -> None:
request_thread = Thread(target=DbusWrapper.set_systray_toggle_thread, kwargs={'name': name, 'enabled': enabled})
request_thread.start()

@staticmethod
def set_systray_toggle_thread(name: str, enabled: bool):
asyncio.run(DbusWrapper.set_systray_toggle_async(name, enabled))

@staticmethod
async def set_systray_toggle_async(name: str, enabled: bool):
dbus_bus = await MessageBus().connect()
await dbus_bus.call(Message(
destination=DBUS_BUS_NAME,
path=DBUS_SETTINGS_OBJECT_PATH,
interface=DBUS_SETTINGS_INTERFACE_NAME,
member='SetSystrayToggle',
message_type=MessageType.METHOD_CALL,
signature='sb',
body=[name, enabled],
))

7 changes: 6 additions & 1 deletion src/linux_arctis_manager/gui/main_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from linux_arctis_manager.gui.dbus_wrapper import DbusWrapper
from linux_arctis_manager.gui.main_app_proto_widget import QMainAppProtoWidget
from linux_arctis_manager.gui.settings_widget import QSettingsWidget
from linux_arctis_manager.gui.systray_toggles_widget import QSystrayTogglesWidget
from linux_arctis_manager.gui.status_widget import QStatusWidget
from linux_arctis_manager.gui.ui_utils import get_icon_pixmap
from linux_arctis_manager.i18n import I18n
Expand Down Expand Up @@ -44,11 +45,13 @@ def __init__(self, app: QApplication, log_level: int):
self.status_widget = QStatusWidget(self.main_panel)
self.general_settings_widget = QSettingsWidget(self.main_panel, 'general', 'general')
self.device_settings_widget = QSettingsWidget(self.main_panel, 'device', 'device')
self.systray_toggles_widget = QSystrayTogglesWidget(self.main_panel)

self.main_panel_widgets: dict[str, QWidget] = {
'status': self.status_widget,
'general': self.general_settings_widget,
'device': self.device_settings_widget,
'systray_toggles': self.systray_toggles_widget,
}

for widget in self.main_panel_widgets.values():
Expand All @@ -58,6 +61,7 @@ def __init__(self, app: QApplication, log_level: int):
self.dbus_wrapper.sig_status.connect(self.status_widget.update_status)
self.dbus_wrapper.sig_settings.connect(self.general_settings_widget.update_settings)
self.dbus_wrapper.sig_settings.connect(self.device_settings_widget.update_settings)
self.dbus_wrapper.sig_settings.connect(self.systray_toggles_widget.update_settings)

self.switch_panel('status')
self.dbus_wrapper.start()
Expand Down Expand Up @@ -99,6 +103,7 @@ def main_window_setup(self) -> QMainAppProtoWidget:
('status', I18n.get_instance().translate('ui', 'status')),
('general', I18n.get_instance().translate('ui', 'general')),
('device', I18n.get_instance().translate('ui', 'device')),
('systray_toggles', I18n.get_instance().translate('ui', 'systray_toggles')),
]

for value, text in self.side_panel_items:
Expand All @@ -118,7 +123,7 @@ def main_window_setup(self) -> QMainAppProtoWidget:

return window

def switch_panel(self, panel: Literal['status', 'general', 'device']) -> None:
def switch_panel(self, panel: Literal['status', 'general', 'device', 'systray_toggles']) -> None:
if not self.main_panel_widgets[panel].isHidden():
return

Expand Down
40 changes: 40 additions & 0 deletions src/linux_arctis_manager/gui/systray_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def __init__(self, app: QApplication, log_level: int):
lang_code = lang_code.split('_')[0] if lang_code else 'en'

self.last_device_status = {}
self.last_device_settings = {}

self.menu = QMenu()
self.menu_setup()
Expand All @@ -60,6 +61,7 @@ def __init__(self, app: QApplication, log_level: int):

self.dbus_wrapper = DbusWrapper()
self.dbus_wrapper.sig_status.connect(lambda status: self.new_status.emit(status or {}))
self.dbus_wrapper.sig_settings.connect(self.on_new_settings)

self.tray_icon.setContextMenu(self.menu)

Expand All @@ -70,6 +72,13 @@ def on_new_status(self, status: dict[str, dict[str, dict[str, str|int]]]):
self.last_device_status = status
self.menu_setup()

def on_new_settings(self, settings: dict):
if self.last_device_settings == settings:
return

self.last_device_settings = settings
self.menu_setup()

async def start(self):
self.logger.info('Starting Systray app.')
self.tray_icon.show()
Expand All @@ -85,6 +94,37 @@ def menu_setup(self) -> None:
self._menu_actions['open_app'].triggered.connect(self.open_main_window)
self.menu.addAction(self._menu_actions['open_app'])

device_settings = self.last_device_settings.get('device', {})
settings_config = self.last_device_settings.get('settings_config', {})
pinned = self.last_device_settings.get('systray_toggles', [])

for name in pinned:
config = settings_config.get(name)
if not config or config.get('type') != 'toggle':
continue

values = config.get('values', {})
# Device settings are stored as ints; coerce so the values sent over
# D-Bus match the int-typed default and aren't rejected as a type mismatch.
on_value = int(values.get('on', 1))
off_value = int(values.get('off', 0))
current_value = device_settings.get(name, off_value)
is_on = current_value == on_value

action = QAction(I18n.translate('settings', name))
action.setCheckable(True)
action.setChecked(is_on)

def _toggle(checked, n=name, on_val=on_value, off_val=off_value):
new_value = on_val if checked else off_val
if 'device' in self.last_device_settings:
self.last_device_settings['device'][n] = new_value
DbusWrapper.change_setting(n, new_value)

action.triggered.connect(_toggle)
self._menu_actions['toggle_' + name] = action
self.menu.addAction(action)

sections = 0
for _, status_obj in self.last_device_status.items():
if not status_obj:
Expand Down
Loading