Skip to content

feat: Built-in NitroSense/Turbo Key Monitor, Live GUI Sync & Clean Event Architecture - #258

Open
bymayfe wants to merge 2 commits into
PXDiv:mainfrom
bymayfe:feat/native-key-monitor-live-sync
Open

feat: Built-in NitroSense/Turbo Key Monitor, Live GUI Sync & Clean Event Architecture#258
bymayfe wants to merge 2 commits into
PXDiv:mainfrom
bymayfe:feat/native-key-monitor-live-sync

Conversation

@bymayfe

@bymayfe bymayfe commented Aug 27, 2026

Copy link
Copy Markdown

[PR / Feature Draft] Built-in NitroSense/Turbo Key Monitor, Live GUI Sync & Clean Event Architecture

📌 Summary & Motivation

This Pull Request brings complete out-of-the-box hardware key integration and resolves thermal profile synchronization bugs between the background daemon and the Avalonia GUI:

  1. Native Hotkey & Turbo Key Daemon Monitor (DAMM-Daemon/KeyboardMonitor.py):

    • Implements native evdev multi-device input monitoring directly in DAMX-Daemon, eliminating the need for external shell scripts or third-party wrappers.
    • NitroSense ('N' Key - code 148 / 425): Automatically detects the active desktop user and launches/toggles the DAMX GUI application.
    • Gaming Turbo Key (code 149 / 202 / 203): Performs native 5-stage AC and 2-stage Battery thermal profile cycling directly in the daemon, adjusts fan speeds accordingly, and sends desktop OSD notifications (notify-send).
    • Touchpad Toggle (code 530 / 531 / 532): Built-in handler for touchpad shortcut.
  2. Battery Mode (low-power) Compatibility Fix (DAMM-Daemon/PowerSourceDetection.py):

    • Added "low-power" to the allowed battery profile list, preventing the daemon from erroneously resetting Eco mode back to Balanced on battery.
  3. Live GUI Sync & Startup State Guard (DivAcerManagerMax):

    • Fix Cold Startup Override: Removed hardcoded IsChecked="True" from BalancedProfileButton and added _isInitialized guard to stop the GUI from overriding the active hardware profile on startup.
    • Live Dynamic Sync: Added a DispatcherTimer polling loop to automatically update the open GUI when thermal modes change externally (via Turbo key, CLI, or power source transition).
    • Clean Event Architecture: Migrated thermal profile buttons from IsCheckedChanged to .Click (ProfileButton_Click). This completely eliminates programmatic feedback loops (where programmatic IsChecked changes triggered artificial user click events that fought with the daemon).

🔍 Code Comparisons & Git Diff

1. DAMM-Daemon/KeyboardMonitor.py (NEW / REWRITTEN)

#!/usr/bin/env python3
"""
KeyboardMonitor - Built-in Acer Nitro/Predator Hotkey and Turbo Button Monitor
Handles KEY_PROG1 (NitroSense Key, 148/425) and KEY_PROG2 (Gaming Turbo Key, 149/202/203)
directly within DAMX-Daemon.
"""

import os
import glob
import struct
import select
import subprocess
import threading
import logging
import time
from pathlib import Path

IS_64BIT = struct.calcsize("P") == 8
EVENT_SIZE = 24 if IS_64BIT else 16

EV_KEY = 1
KEY_PRESS = 1

KEY_NITROSENSE = 148   # KEY_PROG1 (NitroSense 'N' button)
KEY_NITRO_ALT = 425    # Alternate vendor keycode
KEY_TURBO = 149        # KEY_PROG2 (Acer Gaming Turbo / Thermal mode button)


class KeyboardMonitor:
    def __init__(self, manager=None, logger=None):
        self.manager = manager
        self.log = logger or logging.getLogger("KeyboardMonitor")
        self.running = False
        self.monitor_thread = None
        self.lock = threading.Lock()
        self.last_press_time = {}

    def find_target_user(self):
        try:
            result = subprocess.run(
                ['loginctl', 'list-sessions', '--no-legend'],
                capture_output=True, text=True, timeout=2
            )
            for line in result.stdout.strip().splitlines():
                parts = line.split()
                if len(parts) >= 3 and parts[2] not in ('root', 'gdm', 'sddm', 'lightdm'):
                    return parts[2]
        except Exception:
            pass

        user = os.environ.get('SUDO_USER')
        if user and user != 'root':
            return user

        try:
            result = subprocess.run(['who'], capture_output=True, text=True, timeout=2)
            for line in result.stdout.splitlines():
                parts = line.split()
                if parts and parts[0] != 'root':
                    return parts[0]
        except Exception:
            pass

        return None

    def get_user_session_env(self, target_user):
        env = {'DISPLAY': ':0', 'WAYLAND_DISPLAY': 'wayland-0'}
        try:
            uid = subprocess.run(['id', '-u', target_user], capture_output=True, text=True).stdout.strip()
            env['XDG_RUNTIME_DIR'] = f"/run/user/{uid}"
            env['DBUS_SESSION_BUS_ADDRESS'] = f"unix:path=/run/user/{uid}/bus"
        except Exception:
            env['XDG_RUNTIME_DIR'] = "/run/user/1000"
            env['DBUS_SESSION_BUS_ADDRESS'] = "unix:path=/run/user/1000/bus"

        try:
            pids = subprocess.run(['pgrep', '-u', target_user], capture_output=True, text=True).stdout.split()
            for pid in pids[:10]:
                environ_path = f"/proc/{pid}/environ"
                if os.path.exists(environ_path):
                    try:
                        with open(environ_path, 'rb') as f:
                            raw = f.read().split(b'\0')
                            for entry in raw:
                                if entry.startswith(b'WAYLAND_DISPLAY='):
                                    env['WAYLAND_DISPLAY'] = entry.decode('utf-8', errors='ignore').split('=', 1)[1]
                                elif entry.startswith(b'DISPLAY='):
                                    env['DISPLAY'] = entry.decode('utf-8', errors='ignore').split('=', 1)[1]
                                elif entry.startswith(b'DBUS_SESSION_BUS_ADDRESS='):
                                    env['DBUS_SESSION_BUS_ADDRESS'] = entry.decode('utf-8', errors='ignore').split('=', 1)[1]
                                elif entry.startswith(b'XDG_RUNTIME_DIR='):
                                    env['XDG_RUNTIME_DIR'] = entry.decode('utf-8', errors='ignore').split('=', 1)[1]
                    except Exception:
                        continue
        except Exception:
            pass

        return env

    def launch_or_toggle_gui(self):
        target_user = self.find_target_user()
        if not target_user:
            return

        env = self.get_user_session_env(target_user)
        try:
            pgrep_res = subprocess.run(['pgrep', '-f', 'DivAcerManagerMax'], capture_output=True, text=True)
            if pgrep_res.returncode == 0:
                return
        except Exception:
            pass

        cmd = [
            'sudo', '-u', target_user,
            'env',
            f'DISPLAY={env.get("DISPLAY", ":0")}',
            f'WAYLAND_DISPLAY={env.get("WAYLAND_DISPLAY", "wayland-0")}',
            f'XDG_RUNTIME_DIR={env.get("XDG_RUNTIME_DIR", "/run/user/1000")}',
            f'DBUS_SESSION_BUS_ADDRESS={env.get("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/1000/bus")}',
            'nohup', '/usr/bin/damx'
        ]
        try:
            subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
        except Exception as e:
            self.log.error(f"Failed to launch DAMX GUI: {e}")

    def is_on_ac(self):
        for path in glob.glob("/sys/class/power_supply/*/online"):
            if any(name in path for name in ("ACAD", "ADP", "AC0", "AC")):
                try:
                    with open(path, "r") as f:
                        if f.read().strip() == "1":
                            return True
                except Exception:
                    pass
        return False

    def cycle_thermal_profile(self):
        if not self.manager:
            return

        with self.lock:
            on_ac = self.is_on_ac()
            current = self.manager.get_thermal_profile() or "balanced"
            current = current.strip().lower()

            if not on_ac:
                if current == "low-power":
                    next_info = ("balanced", 0, 0, "Dengeli Mod", "battery-charging")
                else:
                    next_info = ("low-power", 0, 0, "ECO Modu (Pil Tasarrufu)", "battery-low")
            else:
                rotations = {
                    "quiet": ("balanced", 0, 0, "Dengeli Mod", "system-run"),
                    "balanced": ("balanced-performance", 75, 75, "Performans Modu", "speedometer"),
                    "balanced-performance": ("performance", 100, 100, "Turbo Modu", "dialog-warning"),
                    "performance": ("quiet", 0, 0, "Sessiz Mod", "audio-volume-muted"),
                }
                next_info = rotations.get(current, ("balanced", 0, 0, "Dengeli Mod", "system-run"))

            target_profile, fan_cpu, fan_gpu, title, icon = next_info
            self.manager.set_thermal_profile(target_profile)
            if hasattr(self.manager, 'set_fan_speed'):
                self.manager.set_fan_speed(fan_cpu, fan_gpu)

            self.send_desktop_notification(f"Termal Mod: {title}", f"Fanlar: %{fan_cpu if fan_cpu > 0 else 'Otomatik'}", icon)

    def send_desktop_notification(self, title, message, icon="preferences-system"):
        target_user = self.find_target_user()
        if not target_user:
            return

        env = self.get_user_session_env(target_user)
        cmd = [
            'sudo', '-u', target_user,
            'env',
            f'DISPLAY={env.get("DISPLAY", ":0")}',
            f'WAYLAND_DISPLAY={env.get("WAYLAND_DISPLAY", "wayland-0")}',
            f'XDG_RUNTIME_DIR={env.get("XDG_RUNTIME_DIR", "/run/user/1000")}',
            f'DBUS_SESSION_BUS_ADDRESS={env.get("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/1000/bus")}',
            'notify-send', '-a', 'DivAcerManagerMax', '-u', 'normal', '-t', '2000', '-i', icon, title, message
        ]
        try:
            subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        except Exception:
            pass

    def find_keyboard_devices(self):
        devices = []
        try:
            devices_path = Path("/proc/bus/input/devices")
            if not devices_path.exists():
                return devices

            with open(devices_path, "r") as f:
                content = f.read()

            for device_block in content.split("\n\n"):
                lines = [l.strip() for l in device_block.split("\n") if l.strip()]
                name = ""
                handlers = ""
                for line in lines:
                    if line.startswith("N: Name="):
                        name = line.split("=", 1)[1].strip('"')
                    elif line.startswith("H: Handlers="):
                        handlers = line.split("=", 1)[1]

                if any(kw in name.lower() for kw in ("acer", "keyboard", "wmi")):
                    for token in handlers.split():
                        if token.startswith("event"):
                            dev_path = f"/dev/input/{token}"
                            if os.path.exists(dev_path) and dev_path not in devices:
                                devices.append(dev_path)
                                self.log.info(f"Found input device '{name}': {dev_path}")
        except Exception as e:
            self.log.error(f"Error finding keyboard devices: {e}")

        return devices

    def monitor_loop(self):
        device_paths = self.find_keyboard_devices()
        if not device_paths:
            return

        file_descriptors = {}
        for path in device_paths:
            try:
                fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
                file_descriptors[fd] = path
            except Exception as e:
                self.log.warning(f"Could not open device {path}: {e}")

        try:
            while self.running:
                rlist, _, _ = select.select(list(file_descriptors.keys()), [], [], 0.5)
                for fd in rlist:
                    try:
                        data = os.read(fd, EVENT_SIZE * 8)
                        for i in range(0, len(data), EVENT_SIZE):
                            chunk = data[i:i + EVENT_SIZE]
                            if len(chunk) != EVENT_SIZE:
                                continue

                            if IS_64BIT:
                                _, _, event_type, code, value = struct.unpack("QQHHi", chunk)
                            else:
                                _, _, event_type, code, value = struct.unpack("IIHHi", chunk)

                            if event_type == EV_KEY and value == KEY_PRESS:
                                now = time.time()
                                if now - self.last_press_time.get(code, 0) < 0.3:
                                    continue
                                self.last_press_time[code] = now

                                if code in (KEY_NITROSENSE, KEY_NITRO_ALT):
                                    self.launch_or_toggle_gui()
                                elif code in (KEY_TURBO, 202, 203):
                                    self.cycle_thermal_profile()
                    except BlockingIOError:
                        continue
                    except Exception as e:
                        self.log.error(f"Error reading from device: {e}")
        finally:
            for fd in file_descriptors:
                try:
                    os.close(fd)
                except Exception:
                    pass

    def start_monitoring(self):
        if self.running:
            return True
        self.running = True
        self.monitor_thread = threading.Thread(target=self.monitor_loop, daemon=True, name="DAMX-KeyboardMonitor")
        self.monitor_thread.start()
        return True

    def stop_monitoring(self):
        self.running = False
        if self.monitor_thread and self.monitor_thread.is_alive():
            self.monitor_thread.join(timeout=2.0)

2. DAMM-Daemon/PowerSourceDetection.py

@@ -111,9 +111,12 @@ class PowerSourceDetector:
             # On battery power - enforce balanced or eco mode
             log.info("Switched to battery power")
 
-            if current_profile not in ["balanced", "quiet", "power-saver"]:
-                # If current profile isn't battery-friendly, switch to balanced
-                if "balanced" in available_profiles:
+            if current_profile not in ["balanced", "quiet", "power-saver", "low-power"]:
+                # If current profile isn't battery-friendly, switch to low-power or balanced
+                if "low-power" in available_profiles:
+                    log.info("Auto-switching to low-power (ECO) mode for battery power")
+                    self.manager.set_thermal_profile("low-power")
+                elif "balanced" in available_profiles:
                     log.info("Auto-switching to balanced mode for battery power")
                     self.manager.set_thermal_profile("balanced")
                 elif "quiet" in available_profiles:

3. DivAcerManagerMax/MainWindow.axaml

@@ -105,7 +105,7 @@
                                             </RadioButton>
 
                                             <RadioButton x:Name="BalancedProfileButton" GroupName="ProfileGroup"
-                                                         Margin="0 0 15 0" IsChecked="True">
+                                                         Margin="0 0 15 0">
                                                 <StackPanel>
                                                     <material:MaterialIcon Kind="Bicycle" Width="48" Height="48" />
                                                     <TextBlock Text="Balanced" HorizontalAlignment="Center" />

4. DivAcerManagerMax/MainWindow.axaml.cs

@@ -10,6 +10,7 @@ using Avalonia.Controls;
 using Avalonia.Interactivity;
 using Avalonia.Markup.Xaml;
 using Avalonia.Media;
+using Avalonia.Threading;
 using MsBox.Avalonia;
 
 namespace DivAcerManagerMax;
@@ -96,13 +97,28 @@ public partial class MainWindow : Window, INotifyPropertyChanged
     private ColorPicker _zone2ColorPicker;
     private ColorPicker _zone3ColorPicker;
     private ColorPicker _zone4ColorPicker;
+    private DispatcherTimer? _syncTimer;
+    private bool _isPolling = false;
+    private bool _isInitialized = false;
 
     public MainWindow()
     {
         InitializeComponent();
         DataContext = this;
         _client = new DAMXClient();
-        Loaded += MainWindow_Loaded;
+
+        BindControls();
+        AttachEventHandlers();
+        InitializeAsync();
+
+        _syncTimer = new DispatcherTimer
+        {
+            Interval = TimeSpan.FromMilliseconds(1000)
+        };
+        _syncTimer.Tick += SyncTimer_Tick;
+        _syncTimer.Start();
+
+        Closing += (_, _) => _syncTimer?.Stop();
     }
@@ -204,11 +220,11 @@ public partial class MainWindow : Window, INotifyPropertyChanged
     private void AttachEventHandlers()
     {
-        if (_lowPowerProfileButton != null) _lowPowerProfileButton.IsCheckedChanged += ProfileButton_Checked;
-        if (_quietProfileButton != null) _quietProfileButton.IsCheckedChanged += ProfileButton_Checked;
-        if (_balancedProfileButton != null) _balancedProfileButton.IsCheckedChanged += ProfileButton_Checked;
-        if (_performanceProfileButton != null) _performanceProfileButton.IsCheckedChanged += ProfileButton_Checked;
-        if (_turboProfileButton != null) _turboProfileButton.IsCheckedChanged += ProfileButton_Checked;
+        if (_lowPowerProfileButton != null) _lowPowerProfileButton.Click += ProfileButton_Click;
+        if (_quietProfileButton != null) _quietProfileButton.Click += ProfileButton_Click;
+        if (_balancedProfileButton != null) _balancedProfileButton.Click += ProfileButton_Click;
+        if (_performanceProfileButton != null) _performanceProfileButton.Click += ProfileButton_Click;
+        if (_turboProfileButton != null) _turboProfileButton.Click += ProfileButton_Click;
@@ -347,6 +363,7 @@ public partial class MainWindow : Window, INotifyPropertyChanged
             {
                 _daemonErrorGrid.IsVisible = false;
                 await LoadSettingsAsync();
+                _isInitialized = true;
             }
@@ -686,9 +703,35 @@ public partial class MainWindow : Window, INotifyPropertyChanged
+    private async void SyncTimer_Tick(object? sender, EventArgs e)
+    {
+        if (!_isConnected || !_isInitialized || _isPolling) return;
+        _isPolling = true;
+        try
+        {
+            var newSettings = await _client.GetAllSettingsAsync();
+            if (newSettings?.ThermalProfile?.Current != null &&
+                !string.IsNullOrEmpty(newSettings.ThermalProfile.Current) &&
+                (_settings?.ThermalProfile?.Current == null ||
+                 !string.Equals(newSettings.ThermalProfile.Current, _settings.ThermalProfile.Current, StringComparison.OrdinalIgnoreCase)))
+            {
+                _settings = newSettings;
+                await Dispatcher.UIThread.InvokeAsync(UpdateProfileButtons);
+            }
+        }
+        catch
+        {
+            // Retry on next tick
+        }
+        finally
+        {
+            _isPolling = false;
+        }
+    }
+
-    private async void ProfileButton_Checked(object sender, RoutedEventArgs e)
+    private async void ProfileButton_Click(object? sender, RoutedEventArgs e)
     {
-        if (!_isConnected || sender is not RadioButton button || button.IsChecked != true) return;
+        if (!_isInitialized || !_isConnected || sender is not RadioButton button || button.IsChecked != true) return;

🧪 Verification & Testing

  • Physical Key Trigger (AC & Battery): Turbo key cycles 4 modes on AC and 2 modes on Battery; OSD notifications appear instantly.
  • Live GUI Reflection: Active profile changes update visually on screen within 1 second without focus loss.
  • Zero Feedback Loops: Click-driven event model prevents programmatic UI updates from bouncing back to the daemon.
  • NitroSense 'N' Key: Automatically summons the DAMX GUI on Wayland & X11 sessions.

- Implement native evdev key monitor in DAMM-Daemon for NitroSense (148/425) and Turbo keys (149/202/203)
- Add low-power mode support in PowerSourceDetection to prevent unwanted mode resets on battery
- Add DispatcherTimer polling in GUI MainWindow for live dynamic sync of thermal profile changes
- Switch thermal profile buttons to .Click event to eliminate programmatic feedback loops
- Remove hardcoded IsChecked on Balanced button and add _isInitialized startup guard
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant