diff --git a/pyproject.toml b/pyproject.toml index bbbca6a58..ca523c7b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ powerbuttond = "hhd.plugins.powerbutton:autodetect" aura = "hhd.device.aura:autodetect" rgb = "hhd.plugins.rgb:autodetect" cec = "hhd.plugins.cec:autodetect" +cooling_dock = "hhd.plugins.cooling_dock:autodetect" overlay = "hhd.plugins.overlay:autodetect" bootc = "hhd.plugins.bootc:autodetect" debug = "hhd.plugins.debug:autodetect" diff --git a/src/hhd/__main__.py b/src/hhd/__main__.py index 70485b6ce..a977f06eb 100644 --- a/src/hhd/__main__.py +++ b/src/hhd/__main__.py @@ -727,10 +727,13 @@ def run_plugin_cmd(cmd: Callable[[HHDPlugin], None], reverse: bool = False): has_new = should_initialize.is_set() saved = False - # Save existing profiles if open - if save_state_yaml(state_fn, settings, conf, shash): - saved = True - conf.updated = False + if conf.updated and not getattr(conf, "yaml_save_queued", 0): + conf.yaml_save_queued = curr + 0.3 + + if conf.updated and curr >= getattr(conf, "yaml_save_queued", 0): + if save_state_yaml(state_fn, settings, conf, shash): + saved = True + conf.yaml_save_queued = 0 for name, prof in profiles.items(): fn = join(profile_dir, name + ".yml") if save_profile_yaml(fn, settings, prof, shash): diff --git a/src/hhd/device/oxp/const.py b/src/hhd/device/oxp/const.py index 4417bddf7..b79b2ce3b 100644 --- a/src/hhd/device/oxp/const.py +++ b/src/hhd/device/oxp/const.py @@ -188,6 +188,16 @@ "mapping": X1_MINI_MAPPING, "protocol": "hid_v2_x2", }, + "ONEXPLAYER SUPER X": { + **ONEX_DEFAULT_CONF, + "name": "ONEXPLAYER SUPER X", + "protocol": "mixed", + }, + "ONEXPLAYER APEX": { + **ONEX_DEFAULT_CONF, + "name": "ONEXPLAYER APEX", + "protocol": "mixed", + }, "ONEXPLAYER G1 i": { **ONEX_DEFAULT_CONF, "name": "ONEXPLAYER G1 (Intel)", diff --git a/src/hhd/plugins/cooling_dock/__init__.py b/src/hhd/plugins/cooling_dock/__init__.py new file mode 100644 index 000000000..ee61d635e --- /dev/null +++ b/src/hhd/plugins/cooling_dock/__init__.py @@ -0,0 +1,3 @@ +from .base import autodetect + +__all__ = ["autodetect"] diff --git a/src/hhd/plugins/cooling_dock/base.py b/src/hhd/plugins/cooling_dock/base.py new file mode 100644 index 000000000..7532a3b48 --- /dev/null +++ b/src/hhd/plugins/cooling_dock/base.py @@ -0,0 +1,833 @@ +import asyncio +import logging +import os +import threading +import time +from dataclasses import replace +from typing import Sequence + +from hhd.plugins import Config, Context, Emitter, HHDPlugin, load_relative_yaml +from hhd.plugins.settings import HHDSettings + +logger = logging.getLogger(__name__) + +SUPPORTED_PRODUCTS = ("ONEXPLAYER SUPER X", "ONEXPLAYER APEX") + +SCAN_BACKOFF_MIN = 5 +SCAN_BACKOFF_MAX = 15 +SCAN_BACKOFF_FACTOR = 2 +GATT_WATCHDOG_TIMEOUT = 20 +GATT_OP_TIMEOUT = 10 +SYNC_RETRY_MAX = 3 +RECONNECT_DELAY = 2 +SYNC_RETRY_DELAY = 2 +SYNC_READ_INTERVAL = 5 # >2s to avoid firmware BLE exhaustion +DOCK_RUNNING_GRACE = 10 # grace before dropping dock_running on BLE drops + +try: + from bleak import BleakClient, BleakScanner + from bleak.backends.device import BLEDevice + + BLEAK_AVAILABLE = True +except ImportError: + BLEAK_AVAILABLE = False + +from .protocol import (CHAR_UUID, CHUNK_DELAY_S, DEVICE_NAME, NOTIFY_UUID, + POST_WRITE_DELAY_S, SERVICE_UUID, TOTAL_BYTES, + WRITE_CMD, WRITE_RETRY_DELAY_S, WRITE_RETRY_MAX, + CoolingStatus, DockMode, build_write_chunks) + + +def get_cpu_temp() -> float: + highest = 0.0 + try: + hwmon_dir = "/sys/class/hwmon" + if not os.path.exists(hwmon_dir): + return highest + for hwmon in os.listdir(hwmon_dir): + path = os.path.join(hwmon_dir, hwmon) + try: + with open(os.path.join(path, "name"), "r") as f: + name = f.read().strip() + if name in ("k10temp", "oxpec", "amdgpu"): + for file in os.listdir(path): + if file.startswith("temp") and file.endswith("_input"): + with open(os.path.join(path, file), "r") as f: + temp = int(f.read().strip()) / 1000.0 + if temp > highest: + highest = temp + except Exception: + continue + except Exception: + pass + return highest + + +def fan_pct_for_temp(temp: float, curve: list[tuple[int, int]]) -> int: + if not curve: + return 0 + sorted_curve = sorted(curve, key=lambda x: x[1]) + if temp <= sorted_curve[0][1]: + return sorted_curve[0][0] + for i in range(1, len(sorted_curve)): + if temp <= sorted_curve[i][1]: + t0, f0 = sorted_curve[i - 1][1], sorted_curve[i - 1][0] + t1, f1 = sorted_curve[i][1], sorted_curve[i][0] + if t1 == t0: + return f1 + ratio = (temp - t0) / (t1 - t0) + return int(f0 + ratio * (f1 - f0)) + return sorted_curve[-1][0] + + +class CoolingDockPlugin(HHDPlugin): + name = "cooling_dock" + priority = 20 + log = "dock" + + def __init__(self) -> None: + self.running = False + self.thread = None + self.conf_lock = threading.Lock() + self.conf = None + self.enabled = False + self.mode = "auto" + self.fan_curve = self._default_curve() + self.rgb_enable = True + self.rgb_mode = 1 + self.rgb_level = 3 + self._last_fan_pct = -1 + self._dock_running = False + self._status = "Disconnected" + self._fan_progress = None + self._scan_delay = SCAN_BACKOFF_MIN + self._last_gatt_read = 0.0 + self._mac_address = "" + self._is_water_cooled = False + self._force_reconnect = False + self._scan_requested = False + self._discovered_macs = {"": "None"} + self._last_write_target = None + self._last_write_time = 0.0 + self._last_connected_time = 0.0 + self._last_disconnect_time = None + + def _default_curve(self) -> list[tuple[int, int]]: + return [(0, 40), (30, 50), (50, 60), (70, 70), (85, 80)] + + def open(self, emit: Emitter, context: Context): + self.emit = emit + if not BLEAK_AVAILABLE: + logger.warning("Bleak not available, Cooling Dock plugin disabled.") + return + self.running = True + self.thread = threading.Thread(target=self._run_loop, daemon=True) + self.thread.start() + + def close(self): + self.running = False + if self.thread: + self.thread.join(timeout=3) + + def settings(self) -> HHDSettings: + base = {"cooling_dock": {"dock": load_relative_yaml("settings.yml")}} + if not BLEAK_AVAILABLE: + base["cooling_dock"]["dock"]["children"]["enabled"][ + "hint" + ] = "Bleak is not installed. Install with: pip install bleak" + else: + with self.conf_lock: + opts = self._discovered_macs.copy() + if self._mac_address and self._mac_address not in opts: + opts[self._mac_address] = ( + f"CoolingSystem_ONEC1 ({self._mac_address})" + ) + base["cooling_dock"]["dock"]["children"]["mac_address"][ + "options" + ] = opts + + # Hide controls when disabled or no dock selected + if not self.enabled or not self._mac_address: + children = base["cooling_dock"]["dock"]["children"] + for key in ["mode", "fan_curve", "rgb", "status", "fan_progress", "forget_dock"]: + if key in children: + del children[key] + + return base + + def update(self, conf: Config): + try: + dock_conf = conf["cooling_dock.dock"] + except Exception: + return + + settings_dirty = False + with self.conf_lock: + self.conf = conf + + old_enabled = self.enabled + old_mac = self._mac_address + + self.enabled = dock_conf.get("enabled", True) + self.mode = dock_conf.get("mode", "auto") + + curve = [] + for i in range(1, 6): + t = dock_conf.get(f"fan_curve.t{i}", None) + f = dock_conf.get(f"fan_curve.f{i}", None) + if t is not None and f is not None: + curve.append((int(f), int(t))) + if curve: + self.fan_curve = curve + + self.rgb_enable = dock_conf.get("rgb.enable", True) + self.rgb_mode = int(dock_conf.get("rgb.mode", 1)) + self.rgb_level = int(dock_conf.get("rgb.level", 3)) + + self._mac_address = dock_conf.get("mac_address", "") + + if self.enabled != old_enabled or self._mac_address != old_mac: + settings_dirty = True + + if conf.get("cooling_dock.dock.forget_dock", False): + conf["cooling_dock.dock.forget_dock"] = False + self._forget_bluez_device() + self._mac_address = "" + conf["cooling_dock.dock.mac_address"] = "" + self._force_reconnect = True + settings_dirty = True + + + if conf.get("cooling_dock.dock.scan_dock", False): + conf["cooling_dock.dock.scan_dock"] = False + self._scan_requested = True + self._force_reconnect = True + + conf["cooling_dock.dock_running"] = self._dock_running + conf["cooling_dock.dock.status"] = self._status + conf["cooling_dock.dock.fan_progress"] = self._fan_progress + + + emit = getattr(self, "emit", None) + if settings_dirty and emit: + emit({"type": "settings"}) + + def _publish_dock_running(self, running: bool): + with self.conf_lock: + conf = self.conf + if running == self._dock_running: + return + self._dock_running = running + if conf is not None: + conf["cooling_dock.dock_running"] = running + + def _publish_status(self, status: str, fan_progress: dict | None): + with self.conf_lock: + conf = self.conf + if status == self._status and fan_progress == self._fan_progress: + return + self._status = status + self._fan_progress = fan_progress + if conf is not None: + conf["cooling_dock.dock.status"] = status + conf["cooling_dock.dock.fan_progress"] = fan_progress + + def _publish_disconnected_if_stale(self): + if ( + self._last_connected_time + and time.time() - self._last_connected_time > DOCK_RUNNING_GRACE + ): + self._publish_dock_running(False) + self._publish_status("Disconnected", None) + + async def _write_state(self, client, state: bytearray): + """Write state to dock using chunked protocol (3x20-byte frames).""" + chunks = build_write_chunks(state) + last_error = None + for attempt in range(WRITE_RETRY_MAX): + try: + for chunk in chunks: + await asyncio.wait_for( + client.write_gatt_char(CHAR_UUID, chunk, response=True), + timeout=GATT_OP_TIMEOUT, + ) + await asyncio.sleep(CHUNK_DELAY_S) + await asyncio.sleep(POST_WRITE_DELAY_S) + return + except Exception as e: + last_error = e + logger.warning( + f"Cooling Dock chunked write failed " + f"({attempt + 1}/{WRITE_RETRY_MAX}): {e}" + ) + if attempt < WRITE_RETRY_MAX - 1: + await asyncio.sleep(WRITE_RETRY_DELAY_S) + raise last_error if last_error else RuntimeError("write failed") + + def _run_loop(self): + asyncio.run(self._async_loop()) + + async def _async_loop(self): + while self.running: + if not self.enabled: + self._publish_dock_running(False) + self._publish_status("Disconnected", None) + self._scan_delay = SCAN_BACKOFF_MIN + await asyncio.sleep(5) + continue + + if not self._mac_address and not self._scan_requested: + self._publish_dock_running(False) + self._publish_status("No dock selected", None) + self._scan_delay = SCAN_BACKOFF_MIN + await asyncio.sleep(2) + continue + try: + self._force_reconnect = False + await self._connect_and_sync() + except Exception as e: + logger.error(f"Cooling Dock error: {e}") + if self.running: + for _ in range(5): + if ( + not self.running + or self._force_reconnect + or self._scan_requested + ): + break + await asyncio.sleep(1) + + async def _connect_and_sync(self): + + self._publish_disconnected_if_stale() + + ble_device = await self._find_dock() + if not ble_device: + logger.info(f"Cooling Dock not found, retrying in {self._scan_delay}s...") + self._publish_disconnected_if_stale() + delay = self._scan_delay + + self._scan_delay = min( + self._scan_delay * SCAN_BACKOFF_FACTOR, SCAN_BACKOFF_MAX + ) + + for _ in range(delay): + if not self.running or self._force_reconnect or self._scan_requested: + break + await asyncio.sleep(1) + return + + + self._scan_delay = SCAN_BACKOFF_MIN + + addr = ble_device.address + logger.info(f"Connecting to Cooling Dock at {addr}...") + + + disconnected_event = asyncio.Event() + + def _on_disconnect(c): + logger.info("Cooling Dock BLE link lost (disconnected callback)") + disconnected_event.set() + + client = BleakClient( + ble_device, timeout=15, disconnected_callback=_on_disconnect + ) + + for attempt in range(3): + try: + await client.connect() + break + except Exception as e: + if attempt == 2: + logger.warning("Connection failed after 3 attempts, clearing BlueZ bond to self-heal.") + self._remove_stale_bond(addr) + raise + logger.warning(f"Cooling Dock connect retry ({attempt + 1}/3): {e}") + await asyncio.sleep(2) + if not client.is_connected: + logger.warning("Failed to connect to Cooling Dock") + self._publish_disconnected_if_stale() + await asyncio.sleep(5) + return + + logger.info("Cooling Dock connected!") + self._publish_status("Connected", None) + self._last_gatt_read = time.time() + now = time.time() + + if self._last_disconnect_time: + gap = now - self._last_disconnect_time + logger.info(f"BLE reconnect after {gap:.1f}s gap") + self._last_disconnect_time = None + self._last_connected_time = now + + + if not self._mac_address: + with self.conf_lock: + self._mac_address = addr + if self.conf is not None: + self.conf["cooling_dock.dock.mac_address"] = addr + + # Register MAC for UI without emitting settings (avoids reload storm) + with self.conf_lock: + if addr not in self._discovered_macs: + self._discovered_macs[addr] = f"CoolingSystem_ONEC1 ({addr})" + + consecutive_errors = 0 + while ( + self.running + and client.is_connected + and not self._force_reconnect + and not disconnected_event.is_set() + ): + + if time.time() - self._last_gatt_read > GATT_WATCHDOG_TIMEOUT: + logger.warning( + f"Dock GATT read timeout ({GATT_WATCHDOG_TIMEOUT}s), " + f"assuming disconnected." + ) + break + + try: + current = await asyncio.wait_for( + client.read_gatt_char(CHAR_UUID), timeout=GATT_OP_TIMEOUT + ) + self._last_gatt_read = time.time() + status = CoolingStatus.from_bytes(current) + + + if status.pump_speed_percent > 0 or status.water_flow > 0: + self._is_water_cooled = True + + self._publish_dock_running( + status.fan_speed > 0 or status.fan_speed_percent > 0 + ) + + if status.mode == 0: + status_str = "Connected - Stopped" + ui_fan_pct = 0 + elif self._is_water_cooled: + status_str = ( + f"Connected (Water) - Fan {status.fan_speed_percent}% " + f"Pump {status.pump_speed_percent}%" + ) + ui_fan_pct = status.fan_speed_percent + else: + status_str = ( + f"Connected (Air) - Fan {status.fan_speed_percent}% " + f"({status.fan_speed} RPM)" + ) + ui_fan_pct = status.fan_speed_percent + + self._publish_status( + status_str, + { + "value": ui_fan_pct, + "max": 100, + "unit": "%", + "text": "Dock Fan", + }, + ) + + with self.conf_lock: + mode = self.mode + curve = list(self.fan_curve) + rgb_en = self.rgb_enable + rgb_m = self.rgb_mode + rgb_lvl = self.rgb_level + + payload = bytearray(current) + payload[1] = WRITE_CMD + + if mode == "auto": + temp = get_cpu_temp() + fan_pct = fan_pct_for_temp(temp, curve) + payload[4] = int(DockMode.AUTO) + payload[15] = 0xFE + idx = 23 + for f, t in curve: + if idx + 1 < len(payload): + payload[idx] = f + payload[idx + 1] = t + idx += 2 + logger.debug( + f"Auto: temp={temp:.1f}C fan={fan_pct}% " + f"(dock reports {status.fan_speed} RPM)" + ) + else: + try: + mode_val = int(mode) + except ValueError: + mode_val = int(DockMode.AUTO) + payload[4] = mode_val + + payload[16] = rgb_m + payload[17] = 1 if rgb_en else 0 + payload[19] = rgb_lvl + + # Only write on change + target = (mode, tuple(curve), rgb_en, rgb_m, rgb_lvl) + now = time.time() + target_changed = target != self._last_write_target + if target_changed: + logger.info(f"Writing to dock: changed={target_changed}") + await self._write_state(client, payload) + self._last_write_target = target + self._last_write_time = now + + consecutive_errors = 0 + await asyncio.sleep(SYNC_READ_INTERVAL) + + except asyncio.TimeoutError: + consecutive_errors += 1 + logger.warning( + f"Cooling Dock GATT timeout " + f"({consecutive_errors}/{SYNC_RETRY_MAX})" + ) + if consecutive_errors >= SYNC_RETRY_MAX: + logger.error("Too many GATT timeouts, disconnecting.") + break + await asyncio.sleep(SYNC_RETRY_DELAY) + + except Exception as e: + consecutive_errors += 1 + logger.warning( + f"Cooling Dock sync error ({consecutive_errors}/" + f"{SYNC_RETRY_MAX}): {e}" + ) + if consecutive_errors >= SYNC_RETRY_MAX: + logger.error("Too many sync errors, disconnecting.") + break + await asyncio.sleep(SYNC_RETRY_DELAY) + + try: + await client.disconnect() + except Exception: + pass + + self._force_reconnect = False + self._last_disconnect_time = time.time() + # Grace period handles dock_running; pause for BlueZ cleanup + for _ in range(RECONNECT_DELAY): + if not self.running or self._force_reconnect or self._scan_requested: + break + await asyncio.sleep(1) + + async def _bluez_start_discovery(self, target_mac: str | None, timeout: int = 10): + """Trigger BlueZ to scan for BLE devices via D-Bus and wait until found.""" + try: + import dbus + + bus = dbus.SystemBus() + adapter = dbus.Interface( + bus.get_object("org.bluez", "/org/bluez/hci0"), "org.bluez.Adapter1" + ) + try: + adapter.StartDiscovery() + except dbus.DBusException as e: + if e.get_dbus_name() != "org.bluez.Error.InProgress": + raise + + + for _ in range(timeout): + if not self.running: + break + device = self._find_dock_in_bluez_objects(target_mac) + if device: + break + await asyncio.sleep(1) + + try: + adapter.StopDiscovery() + except dbus.DBusException: + pass + except Exception as e: + logger.debug(f"BlueZ start discovery failed: {e}") + + def _find_dock_in_bluez_objects( + self, target_mac: str | None = None + ) -> BLEDevice | None: + """Query BlueZ D-Bus for the dock (finds bonded/non-advertising devices).""" + try: + import dbus + + bus = dbus.SystemBus() + obj = bus.get_object("org.bluez", "/") + om = dbus.Interface(obj, "org.freedesktop.DBus.ObjectManager") + objects = om.GetManagedObjects() + + for path, ifaces in objects.items(): + props = ifaces.get("org.bluez.Device1") + if not props: + continue + + name = str(props.get("Name", props.get("Alias", ""))) + mac = str(props.get("Address", "")).upper() + + if target_mac and mac != target_mac.upper(): + continue + + name_match = "Cooling" in name + mac_match = "C8:17:17" in mac + + if not target_mac and not (name_match or mac_match): + continue + + logger.info(f"Found dock in BlueZ D-Bus: {name} ({mac})") + return BLEDevice(mac, name, {"path": str(path)}) + except ImportError: + pass + except Exception as e: + logger.debug(f"BlueZ D-Bus object lookup failed: {e}") + return None + + async def _find_dock(self) -> BLEDevice | None: + try: + with self.conf_lock: + scan_req = self._scan_requested + self._scan_requested = False + + if scan_req: + self._publish_status("Scanning...", None) + + target_mac = self._mac_address.upper() if self._mac_address else None + + if not target_mac and not scan_req: + return None + + device = self._find_dock_in_bluez_objects(target_mac) + if device: + + if scan_req: + await self._populate_dropdown() + return device + + scan_timeout = 10 + await self._bluez_start_discovery(target_mac, timeout=scan_timeout) + + if scan_req: + await self._populate_dropdown() + + device = self._find_dock_in_bluez_objects(target_mac) + if device: + return device + + + if target_mac: + device = await BleakScanner.find_device_by_address( + target_mac, timeout=5 + ) + if device: + return device + + device = await BleakScanner.find_device_by_name(DEVICE_NAME, timeout=10) + return device + + except Exception as e: + logger.debug(f"BLE scan error: {e}") + return None + + async def _populate_dropdown(self): + """Populate the UI dropdown with discovered dock devices.""" + discovered = {"": "None"} + found_macs = [] + + try: + import dbus + + bus = dbus.SystemBus() + om = dbus.Interface( + bus.get_object("org.bluez", "/"), "org.freedesktop.DBus.ObjectManager" + ) + objects = om.GetManagedObjects() + for path, ifaces in objects.items(): + props = ifaces.get("org.bluez.Device1") + if not props: + continue + name = str(props.get("Name", props.get("Alias", ""))) + mac = str(props.get("Address", "")).upper() + if "Cooling" in name: + discovered[mac] = f"{name} ({mac})" + found_macs.append(mac) + except Exception as e: + logger.debug(f"D-Bus dropdown populate failed: {e}") + + try: + devices = await BleakScanner.discover(timeout=3) + for d in devices: + if d.name and "Cooling" in d.name: + discovered[d.address.upper()] = f"{d.name} ({d.address.upper()})" + found_macs.append(d.address.upper()) + except Exception: + pass + + with self.conf_lock: + old_keys = set(self._discovered_macs.keys()) + new_keys = set(discovered.keys()) + self._discovered_macs = discovered + + + if self._mac_address == "" and found_macs: + self._mac_address = found_macs[0] + if self.conf is not None: + self.conf["cooling_dock.dock.mac_address"] = found_macs[0] + + if old_keys != new_keys and self.emit: + self.emit({"type": "settings"}) + + def _remove_stale_bond(self, mac: str): + """Remove BlueZ bond. The dock's HID profile triggers SMP pairing with + random addresses, so bonds go stale quickly and block reconnection.""" + if not mac: + return + + try: + import dbus + + bus = dbus.SystemBus() + obj = bus.get_object("org.bluez", "/") + om = dbus.Interface(obj, "org.freedesktop.DBus.ObjectManager") + objects = om.GetManagedObjects() + for path, ifaces in sorted(objects.items()): + props = ifaces.get("org.bluez.Device1") + if not props: + continue + if str(props.get("Address", "")).upper() != mac.upper(): + continue + logger.info(f"Removing stale BlueZ bond for {mac}") + adapter = dbus.Interface( + bus.get_object("org.bluez", str(path).rsplit("/", 1)[0]), + "org.bluez.Adapter1", + ) + adapter.RemoveDevice(dbus.ObjectPath(path)) + import time + + time.sleep(0.5) + return + except ImportError: + pass # python-dbus not installed, fall through + except Exception as e: + logger.debug(f"BlueZ D-Bus bond removal failed: {e}") + + + try: + import subprocess + + subprocess.run( + ["bluetoothctl", "remove", mac], + capture_output=True, + text=True, + timeout=3, + ) + logger.info(f"Removed stale bond for {mac} via bluetoothctl") + except Exception as e: + logger.debug(f"bluetoothctl remove failed: {e}") + + def _forget_bluez_device(self): + """Remove the dock from BlueZ entirely so it stops auto-connecting.""" + mac = self._mac_address + if not mac: + return + try: + import dbus + + bus = dbus.SystemBus() + obj = bus.get_object("org.bluez", "/") + om = dbus.Interface(obj, "org.freedesktop.DBus.ObjectManager") + objects = om.GetManagedObjects() + for path, ifaces in sorted(objects.items()): + props = ifaces.get("org.bluez.Device1") + if not props: + continue + if str(props.get("Address", "")).upper() != mac.upper(): + continue + logger.info(f"Removing Cooling Dock {mac} from BlueZ") + adapter = dbus.Interface( + bus.get_object("org.bluez", str(path).rsplit("/", 1)[0]), + "org.bluez.Adapter1", + ) + adapter.RemoveDevice(dbus.ObjectPath(path)) + return + except Exception as e: + logger.debug(f"BlueZ forget failed: {e}") + + def _find_connected_dock_via_bluez( + self, target_mac: str | None = None + ) -> BLEDevice | None: + """Find a dock already connected to BlueZ (not advertising).""" + try: + import dbus + + bus = dbus.SystemBus() + obj = bus.get_object("org.bluez", "/") + om = dbus.Interface(obj, "org.freedesktop.DBus.ObjectManager") + objects = om.GetManagedObjects() + for path, ifaces in sorted(objects.items()): + props = ifaces.get("org.bluez.Device1") + if not props: + continue + if not bool(props.get("Connected", False)): + continue + name = str(props.get("Name", "")) + if "Cooling" not in name: + continue + mac = str(props.get("Address", "")).upper() + if target_mac and mac != target_mac: + continue + logger.info(f"Found connected Cooling Dock at {mac}") + return BLEDevice(mac, name, {"path": str(path)}) + except Exception as e: + logger.debug(f"BlueZ D-Bus connected-dock check failed: {e}") + + + try: + import subprocess + + result = subprocess.run( + ["bluetoothctl", "devices"], + capture_output=True, + text=True, + timeout=2, + ) + for line in result.stdout.split("\n"): + if "CoolingSystem" not in line and "Cooling" not in line: + continue + parts = line.strip().split(" ", 2) + if len(parts) < 3: + continue + mac = parts[1].upper() + if target_mac and mac != target_mac: + continue + info = subprocess.run( + ["bluetoothctl", "info", mac], + capture_output=True, + text=True, + timeout=2, + ) + if "Connected: yes" in info.stdout: + logger.info(f"Found connected Cooling Dock at {mac}") + path = f"/org/bluez/hci0/dev_{mac.replace(':', '_')}" + return BLEDevice(mac, parts[2], {"path": path}) + except Exception as e: + logger.debug(f"BlueZ connected-dock check failed: {e}") + return None + + +def _is_supported_device() -> bool: + try: + with open("/sys/devices/virtual/dmi/id/product_name") as f: + prod = f.read().strip() + return prod in SUPPORTED_PRODUCTS + except Exception: + return False + + +def autodetect(existing: Sequence[HHDPlugin]) -> Sequence[HHDPlugin]: + if len([p for p in existing if p.name == "cooling_dock"]): + return existing + + if not _is_supported_device(): + return existing + + return [CoolingDockPlugin()] diff --git a/src/hhd/plugins/cooling_dock/protocol.py b/src/hhd/plugins/cooling_dock/protocol.py new file mode 100644 index 000000000..c7b05498a --- /dev/null +++ b/src/hhd/plugins/cooling_dock/protocol.py @@ -0,0 +1,147 @@ +"""CoolingStatus byte-array protocol for the CoolingSystem_ONEC1 BLE dock. + +GATT: service 0xFFE0, characteristic 0xFFE1 (read+write), 64 bytes. +Read (From) and write (Fill) use DIFFERENT byte indices for some fields. +""" + +from __future__ import annotations +from dataclasses import dataclass, field +from enum import IntEnum + +SERVICE_UUID = "0000ffe0-0000-1000-8000-00805f9b34fb" +CHAR_UUID = "0000ffe1-0000-1000-8000-00805f9b34fb" +NOTIFY_UUID = "0000ffe4-0000-1000-8000-00805f9b34fb" +DEVICE_NAME = "CoolingSystem_ONEC1" + +TOTAL_BYTES = 64 +WRITE_CMD = 0x02 +READ_CMD = 0x10 + +# Chunked write: dock requires 3x20-byte frames with 0x1C/0x2C/0x3C headers +WRITE_PAYLOAD_SIZE = 58 +CHUNK_HEADERS = (0x1C, 0x2C, 0x3C) +CHUNK_SIZE = 19 +CHUNK_DELAY_S = 0.02 +POST_WRITE_DELAY_S = 0.3 +WRITE_RETRY_MAX = 3 +WRITE_RETRY_DELAY_S = 0.5 +ON_RETRY_DELAY_S = 0.5 +MAX_ON_WRITE_RETRIES = 10 + + +class DockMode(IntEnum): + STOPPED = 0x00 + LEVEL_1 = 0x01 + LEVEL_2 = 0x02 + LEVEL_3 = 0x03 + LEVEL_4 = 0x04 + LEVEL_5 = 0x05 + AUTO = 0xFE + MANUAL = 0xFF + + +@dataclass +class CoolingStatus: + version: int = 0 + mode: int = 0 + fan_speed_percent: int = 0 + fan_speed: int = 0 + pump_speed_percent: int = 0 + pump_speed: int = 0 + water_flow: int = 0 + in_water_temp: int = 0 + out_water_temp: int = 0 + status_flag: int = 0 + rgb_mode: int = 0 + rgb_enable: bool = False + rgb_light_level: int = 0 + rgb_r: int = 0 + rgb_g: int = 0 + rgb_b: int = 0 + fan_curve: list[tuple[int, int]] = field( + default_factory=lambda: [(0, 0)] * 9 + ) + + @classmethod + def from_bytes(cls, data: bytes | bytearray) -> "CoolingStatus": + if len(data) < 41: + raise ValueError(f"Need >=41 bytes, got {len(data)}") + s = cls() + s.version = data[2] + s.mode = data[4] + s.fan_speed_percent = data[5] + s.fan_speed = (data[6] << 8) | data[7] + s.pump_speed_percent = data[8] + s.pump_speed = (data[9] << 8) | data[10] + s.water_flow = (data[11] << 8) | data[12] + s.in_water_temp = data[13] + s.out_water_temp = data[14] + s.status_flag = data[15] + s.rgb_mode = data[16] + s.rgb_enable = data[17] == 1 + s.rgb_light_level = data[19] + s.rgb_r = data[20] + s.rgb_g = data[21] + s.rgb_b = data[22] + s.fan_curve = [] + idx = 23 + for _ in range(9): + if idx + 1 < len(data): + s.fan_curve.append((data[idx], data[idx + 1])) + idx += 2 + else: + s.fan_curve.append((0, 0)) + return s + + def to_write_bytes(self, current: bytes | bytearray) -> bytearray: + out = bytearray(current) + out[1] = WRITE_CMD + out[2] = self.version or current[2] + out[4] = self.mode + + out[15] = self.status_flag + out[16] = self.rgb_mode + out[17] = 1 if self.rgb_enable else 0 + out[19] = self.rgb_light_level + out[20] = self.rgb_r + out[21] = self.rgb_g + out[22] = self.rgb_b + idx = 23 + for f, t in self.fan_curve: + out[idx] = f + out[idx + 1] = t + idx += 2 + return out + + def __str__(self) -> str: + lines = [ + f"CoolingStatus v{self.version} mode=0x{self.mode:02X}", + f" Fan: {self.fan_speed_percent:3d}% {self.fan_speed:5d} RPM", + f" Pump: {self.pump_speed_percent:3d}% {self.pump_speed:5d} RPM", + f" Flow: {self.water_flow}", + f" Temp: in={self.in_water_temp}C out={self.out_water_temp}C", + f" RGB: mode=0x{self.rgb_mode:02X} en={self.rgb_enable} lvl={self.rgb_light_level} ({self.rgb_r},{self.rgb_g},{self.rgb_b})", + f" Flag: 0x{self.status_flag:02X}", + " Curve:", + ] + for i, (f, t) in enumerate(self.fan_curve, 1): + lines.append(f" f{i}={f:3d}% @ {t:3d}C") + return "\n".join(lines) + + +def build_write_chunks(state: bytes | bytearray) -> list[bytes]: + """Split a modified 64-byte state into the 3 chunked write frames. + + The dock only accepts writes as 3 x 20-byte frames with 0x1C/0x2C/0x3C + headers; a single 64-byte write is silently ignored. The 58-byte payload + is ``[0x02] + state[2:59]`` (byte 57 of the payload is unused). + """ + payload = bytearray(WRITE_PAYLOAD_SIZE) + payload[0] = WRITE_CMD + payload[1:] = state[2:59] + chunks = [] + for i, header in enumerate(CHUNK_HEADERS): + start = i * CHUNK_SIZE + end = start + CHUNK_SIZE + chunks.append(bytes([header]) + bytes(payload[start:end])) + return chunks diff --git a/src/hhd/plugins/cooling_dock/settings.yml b/src/hhd/plugins/cooling_dock/settings.yml new file mode 100644 index 000000000..f41441936 --- /dev/null +++ b/src/hhd/plugins/cooling_dock/settings.yml @@ -0,0 +1,135 @@ +type: container +title: Cooling Dock +tags: [non-essential] +children: + enabled: + type: bool + title: "Enable Cooling Dock Sync" + hint: | + Automatically connect to the OneXPlayer Cooling Dock over Bluetooth + and sync the fan speed based on CPU temperature. Requires bleak + to be installed and the dock to be paired via bluetoothctl. + default: true + + scan_dock: + type: action + title: "Scan for Devices" + hint: "Click to scan for Bluetooth devices. Will update the list below." + + mac_address: + type: multiple + title: "Dock Device" + hint: "Select your dock from the list." + options: + "": "None" + default: "" + + forget_dock: + type: action + title: "Forget Paired Dock" + hint: "Clears the saved MAC address and forces a new scan." + + status: + type: display + title: "Status" + tags: [slim] + + fan_progress: + type: custom + title: "Dock Fan" + tags: [progress, slim] + + mode: + type: multiple + title: "Fan Mode" + hint: "Select the dock fan operating mode." + options: + "auto": "Auto (CPU temperature curve)" + "1": "Level 1 (25%)" + "2": "Level 2 (50%)" + "3": "Level 3 (75%)" + "4": "Level 4 (100%)" + "0": "Stopped" + default: "auto" + + fan_curve: + type: container + title: "Auto Fan Curve" + hint: "Fan speed percentage at each temperature threshold (Celsius)." + tags: [advanced] + children: + t1: + type: discrete + title: "Temp point 1 (C)" + options: [30, 35, 40, 45] + default: 40 + f1: + type: discrete + title: "Fan % at point 1" + options: [0, 10, 20, 25, 30] + default: 0 + t2: + type: discrete + title: "Temp point 2 (C)" + options: [45, 50, 55, 60] + default: 50 + f2: + type: discrete + title: "Fan % at point 2" + options: [20, 30, 40, 50] + default: 30 + t3: + type: discrete + title: "Temp point 3 (C)" + options: [55, 60, 65, 70] + default: 60 + f3: + type: discrete + title: "Fan % at point 3" + options: [40, 50, 60, 70] + default: 50 + t4: + type: discrete + title: "Temp point 4 (C)" + options: [65, 70, 75, 80] + default: 70 + f4: + type: discrete + title: "Fan % at point 4" + options: [60, 70, 80, 85] + default: 70 + t5: + type: discrete + title: "Temp point 5 (C)" + options: [75, 80, 85, 90] + default: 80 + f5: + type: discrete + title: "Fan % at point 5" + options: [80, 85, 90, 100] + default: 85 + + rgb: + type: container + title: "RGB Lighting" + hint: "Control the dock's RGB lighting." + children: + enable: + type: bool + title: "Enable RGB" + default: true + mode: + type: multiple + title: "RGB Mode" + options: + "0": "Static" + "1": "Breathing" + "2": "Rainbow" + "3": "Wave" + "4": "Pulse" + default: "1" + level: + type: discrete + title: "Brightness" + options: [0, 1, 2, 3, 4, 5] + default: 3 diff --git a/tests/test_cooling_dock.py b/tests/test_cooling_dock.py new file mode 100644 index 000000000..71e3b485c --- /dev/null +++ b/tests/test_cooling_dock.py @@ -0,0 +1,869 @@ +import time +import unittest +from unittest.mock import mock_open, patch, AsyncMock, MagicMock + +from hhd.plugins.cooling_dock.base import ( + CoolingDockPlugin, + _is_supported_device, + SCAN_BACKOFF_MIN, + SCAN_BACKOFF_MAX, + SCAN_BACKOFF_FACTOR, + GATT_OP_TIMEOUT, + SYNC_RETRY_MAX, + SYNC_RETRY_DELAY, + RECONNECT_DELAY, + DOCK_RUNNING_GRACE, +) +from hhd.plugins.cooling_dock.protocol import ( + build_write_chunks, + WRITE_CMD, + CHUNK_HEADERS, + CHUNK_SIZE, + WRITE_RETRY_MAX, +) +from hhd.plugins.conf import Config + + +def make_dock_conf(stale_running: bool = False) -> Config: + return Config( + { + "cooling_dock.dock": { + "enabled": True, + "mode": "auto", + "fan_curve": { + "t1": 40, + "f1": 0, + "t2": 50, + "f2": 30, + "t3": 60, + "f3": 50, + "t4": 70, + "f4": 70, + "t5": 80, + "f5": 85, + }, + "rgb": {"enable": True, "mode": 1, "level": 3}, + }, + "cooling_dock.dock_running": stale_running, + "cooling_dock.dock.status": ( + "Connected - Fan 50% (1000 RPM)" if stale_running else None + ), + } + ) + + +def make_dock_device(mac: str = "AA:BB:CC:DD:EE:FF"): + """Build a BLEDevice-like object with an address for _find_dock tests.""" + device = MagicMock() + device.address = mac + device.name = "CoolingSystem_ONEC1" + return device + + +class CoolingDockPluginTest(unittest.TestCase): + def test_update_self_heals_stale_runtime_state(self): + p = CoolingDockPlugin() + # Simulate a previous session that saved dock_running=True while the + # dock was connected. On startup the plugin must clear it so the + # adjustor does not unlock TDP without an actual dock. + conf = make_dock_conf(stale_running=True) + p.update(conf) + + self.assertEqual(conf["cooling_dock.dock_running"].to(bool), False) + self.assertEqual(conf["cooling_dock.dock.status"].to(str), "Disconnected") + self.assertIsNone(conf["cooling_dock.dock.fan_progress"].conf) + + def test_update_does_not_rewrite_on_second_call(self): + p = CoolingDockPlugin() + conf = make_dock_conf() + p.update(conf) + conf.updated = False + p.update(conf) + self.assertFalse(conf.updated) + + def test_publish_dock_running_writes_on_change(self): + p = CoolingDockPlugin() + conf = make_dock_conf() + p.update(conf) + conf.updated = False + + p._publish_dock_running(True) + self.assertEqual(conf["cooling_dock.dock_running"].to(bool), True) + self.assertTrue(conf.updated) + + def test_publish_status_writes_status_and_progress(self): + p = CoolingDockPlugin() + conf = make_dock_conf() + p.update(conf) + conf.updated = False + + p._publish_status( + "Connected - Fan 50% (1000 RPM)", + {"value": 50, "max": 100, "unit": "%", "text": "Dock Fan"}, + ) + self.assertEqual( + conf["cooling_dock.dock.status"].to(str), + "Connected - Fan 50% (1000 RPM)", + ) + self.assertEqual( + conf["cooling_dock.dock.fan_progress"].to(dict), + {"value": 50, "max": 100, "unit": "%", "text": "Dock Fan"}, + ) + self.assertTrue(conf.updated) + + def test_publish_status_skips_unchanged(self): + p = CoolingDockPlugin() + conf = make_dock_conf() + p.update(conf) + conf.updated = False + + # Same as the initial self-healed state: no write, config stays clean + p._publish_status("Disconnected", None) + self.assertFalse(conf.updated) + + +class ScanBackoffTest(unittest.TestCase): + def test_initial_delay_is_min(self): + p = CoolingDockPlugin() + self.assertEqual(p._scan_delay, SCAN_BACKOFF_MIN) + + def test_backoff_increases(self): + p = CoolingDockPlugin() + # Simulate a failed scan cycle: the delay should increase + initial = p._scan_delay + p._scan_delay = min(p._scan_delay * SCAN_BACKOFF_FACTOR, SCAN_BACKOFF_MAX) + self.assertEqual(p._scan_delay, initial * SCAN_BACKOFF_FACTOR) + + def test_backoff_caps_at_max(self): + p = CoolingDockPlugin() + # Run many backoff steps + for _ in range(20): + p._scan_delay = min( + p._scan_delay * SCAN_BACKOFF_FACTOR, SCAN_BACKOFF_MAX + ) + self.assertLessEqual(p._scan_delay, SCAN_BACKOFF_MAX) + + +class DmiGateTest(unittest.TestCase): + def test_supported_device_superx(self): + with patch( + "builtins.open", + mock_open(read_data="ONEXPLAYER SUPER X"), + ): + self.assertTrue(_is_supported_device()) + + def test_supported_device_apex(self): + with patch( + "builtins.open", + mock_open(read_data="ONEXPLAYER APEX"), + ): + self.assertTrue(_is_supported_device()) + + def test_unsupported_device(self): + with patch( + "builtins.open", + mock_open(read_data="ROG Ally RC71L"), + ): + self.assertFalse(_is_supported_device()) + + def test_missing_dmi_file(self): + with patch("builtins.open", side_effect=FileNotFoundError): + self.assertFalse(_is_supported_device()) + + +class StickyPairingTest(unittest.TestCase): + def test_update_reads_mac_address(self): + p = CoolingDockPlugin() + conf = make_dock_conf() + conf["cooling_dock.dock.mac_address"] = "AA:BB:CC:DD:EE:FF" + p.update(conf) + self.assertEqual(p._mac_address, "AA:BB:CC:DD:EE:FF") + + def test_forget_dock_clears_mac_and_forces_reconnect(self): + p = CoolingDockPlugin() + conf = make_dock_conf() + conf["cooling_dock.dock.mac_address"] = "AA:BB:CC:DD:EE:FF" + conf["cooling_dock.dock.forget_dock"] = True + p.update(conf) + + self.assertEqual(p._mac_address, "") + self.assertEqual(conf["cooling_dock.dock.mac_address"].to(str), "") + self.assertFalse(conf["cooling_dock.dock.forget_dock"].to(bool)) + self.assertTrue(p._force_reconnect) + + +import asyncio + + +class FindDockTest(unittest.IsolatedAsyncioTestCase): + """Tests for the _find_dock discovery logic (Bugs 1 & 6).""" + + async def test_saved_mac_uses_find_device_by_address(self): + """With a saved MAC, _find_dock should verify the dock is in range + via find_device_by_address, not skip scanning entirely.""" + p = CoolingDockPlugin() + conf = make_dock_conf() + conf["cooling_dock.dock.mac_address"] = "AA:BB:CC:DD:EE:FF" + p.update(conf) + + mock_device = MagicMock() + mock_device.address = "AA:BB:CC:DD:EE:FF" + + with patch( + "hhd.plugins.cooling_dock.base.BleakScanner.find_device_by_address", + new_callable=AsyncMock, + return_value=mock_device, + ) as mock_find: + result = await p._find_dock() + mock_find.assert_called_once_with("AA:BB:CC:DD:EE:FF", timeout=5) + self.assertEqual(result.address, "AA:BB:CC:DD:EE:FF") + + async def test_saved_mac_not_in_range_returns_none(self): + """If the saved MAC is not in range, find_device_by_address returns + None and _find_dock returns None quickly (no 15s connect timeout).""" + p = CoolingDockPlugin() + conf = make_dock_conf() + conf["cooling_dock.dock.mac_address"] = "AA:BB:CC:DD:EE:FF" + p.update(conf) + + with patch( + "hhd.plugins.cooling_dock.base.BleakScanner.find_device_by_address", + new_callable=AsyncMock, + return_value=None, + ), patch( + "hhd.plugins.cooling_dock.base.BleakScanner.find_device_by_name", + new_callable=AsyncMock, + return_value=None, + ), patch.object( + p, "_find_dock_in_bluez_objects", return_value=None + ), patch.object( + p, "_bluez_start_discovery", new_callable=AsyncMock + ): + result = await p._find_dock() + self.assertIsNone(result) + + async def test_no_dock_selected_returns_none(self): + """Without a saved MAC, _find_dock should return None without scanning.""" + +class SyncLoopRetryTest(unittest.IsolatedAsyncioTestCase): + """Tests for the sync loop retry logic (Bug 2).""" + + async def test_transient_error_does_not_break_immediately(self): + """A single GATT error should not break the connection — the loop + should retry up to SYNC_RETRY_MAX times.""" + p = CoolingDockPlugin() + p.running = True + conf = make_dock_conf() + p.update(conf) + + # Mock the client and _find_dock to return a connected client + mock_client = MagicMock() + mock_client.is_connected = True + mock_client.connect = AsyncMock() + mock_client.disconnect = AsyncMock() + # First read fails, then all subsequent reads succeed + mock_client.read_gatt_char = AsyncMock( + side_effect=[Exception("transient BLE error")] + + [b"\x00" * 64] * 20 + ) + mock_client.write_gatt_char = AsyncMock() + + # Stop the loop after a few successful cycles by setting running=False + original_sleep = asyncio.sleep + + async def limited_sleep(t): + if mock_client.read_gatt_char.call_count > 3: + p.running = False + await original_sleep(0) + + with patch.object( + p, "_find_dock", new_callable=AsyncMock, return_value=make_dock_device() + ), patch( + "hhd.plugins.cooling_dock.base.BleakClient", return_value=mock_client + ), patch( + "hhd.plugins.cooling_dock.base.asyncio.sleep", side_effect=limited_sleep + ): + await p._connect_and_sync() + # read_gatt_char should have been called more than once (error + retry) + self.assertGreater(mock_client.read_gatt_char.call_count, 1) + # Should NOT have disconnected due to errors — the single error + # was retried and succeeded. Disconnect happened because we set + # running=False to stop the loop. + self.assertLess( + mock_client.read_gatt_char.call_count, SYNC_RETRY_MAX * 3 + ) + + async def test_breaks_after_max_consecutive_errors(self): + """After SYNC_RETRY_MAX consecutive errors, the loop should break + and disconnect.""" + p = CoolingDockPlugin() + p.running = True + conf = make_dock_conf() + p.update(conf) + + mock_client = MagicMock() + mock_client.is_connected = True + mock_client.connect = AsyncMock() + mock_client.disconnect = AsyncMock() + mock_client.read_gatt_char = AsyncMock( + side_effect=Exception("persistent BLE error") + ) + mock_client.write_gatt_char = AsyncMock() + + with patch.object( + p, "_find_dock", new_callable=AsyncMock, return_value=make_dock_device() + ), patch( + "hhd.plugins.cooling_dock.base.BleakClient", return_value=mock_client + ), patch( + "hhd.plugins.cooling_dock.base.asyncio.sleep", new_callable=AsyncMock + ): + await p._connect_and_sync() + # Should have tried SYNC_RETRY_MAX times before breaking + self.assertEqual( + mock_client.read_gatt_char.call_count, SYNC_RETRY_MAX + ) + # Should have disconnected + mock_client.disconnect.assert_called_once() + + async def test_gatt_timeout_handled_as_retryable(self): + """An asyncio.TimeoutError on GATT read should be caught and retried, + not crash the loop.""" + p = CoolingDockPlugin() + p.running = True + conf = make_dock_conf() + p.update(conf) + + mock_client = MagicMock() + mock_client.is_connected = True + mock_client.connect = AsyncMock() + mock_client.disconnect = AsyncMock() + # First read times out, then all subsequent reads succeed + mock_client.read_gatt_char = AsyncMock( + side_effect=[asyncio.TimeoutError()] + [b"\x00" * 64] * 20 + ) + mock_client.write_gatt_char = AsyncMock() + + original_sleep = asyncio.sleep + + async def limited_sleep(t): + if mock_client.read_gatt_char.call_count > 3: + p.running = False + await original_sleep(0) + + with patch.object( + p, "_find_dock", new_callable=AsyncMock, return_value=make_dock_device() + ), patch( + "hhd.plugins.cooling_dock.base.BleakClient", return_value=mock_client + ), patch( + "hhd.plugins.cooling_dock.base.asyncio.sleep", side_effect=limited_sleep + ): + await p._connect_and_sync() + # Should have retried after the timeout (more than 1 call) + self.assertGreater(mock_client.read_gatt_char.call_count, 1) + self.assertLess( + mock_client.read_gatt_char.call_count, SYNC_RETRY_MAX * 3 + ) + + +class DisconnectedCallbackTest(unittest.IsolatedAsyncioTestCase): + """Tests for the disconnected_callback (Bug 7).""" + + async def test_disconnected_callback_breaks_sync_loop(self): + """When the BLE link drops, the disconnected_callback should fire + and break the sync loop immediately.""" + p = CoolingDockPlugin() + p.running = True + conf = make_dock_conf() + p.update(conf) + + mock_client = MagicMock() + mock_client.is_connected = True + mock_client.connect = AsyncMock() + mock_client.disconnect = AsyncMock() + mock_client.read_gatt_char = AsyncMock(return_value=b"\x00" * 64) + mock_client.write_gatt_char = AsyncMock() + + # Capture the disconnected_callback passed to BleakClient + captured_callback = {} + + def capture_client(addr, **kwargs): + captured_callback["cb"] = kwargs.get("disconnected_callback") + return mock_client + + with patch.object( + p, "_find_dock", new_callable=AsyncMock, return_value=make_dock_device() + ), patch( + "hhd.plugins.cooling_dock.base.BleakClient", side_effect=capture_client + ), patch( + "hhd.plugins.cooling_dock.base.asyncio.sleep", new_callable=AsyncMock + ) as mock_sleep: + # Make the first sleep trigger the disconnect callback + async def trigger_disconnect(t): + if captured_callback.get("cb"): + captured_callback["cb"](mock_client) + + mock_sleep.side_effect = trigger_disconnect + + await p._connect_and_sync() + # The callback should have been set + self.assertIsNotNone(captured_callback.get("cb")) + # Should have disconnected + mock_client.disconnect.assert_called_once() + + +class ReconnectDelayTest(unittest.IsolatedAsyncioTestCase): + """Tests for the reconnect delay after disconnect (Bug 3).""" + + async def test_delay_after_disconnect(self): + """After disconnecting, _connect_and_sync should wait RECONNECT_DELAY + seconds before returning (to let BlueZ clean up).""" + p = CoolingDockPlugin() + p.running = True + conf = make_dock_conf() + p.update(conf) + + mock_client = MagicMock() + mock_client.is_connected = True + mock_client.connect = AsyncMock() + mock_client.disconnect = AsyncMock() + mock_client.read_gatt_char = AsyncMock( + side_effect=Exception("connection lost") + ) + mock_client.write_gatt_char = AsyncMock() + + sleep_calls = [] + + async def track_sleep(t): + sleep_calls.append(t) + + with patch.object( + p, "_find_dock", new_callable=AsyncMock, return_value=make_dock_device() + ), patch( + "hhd.plugins.cooling_dock.base.BleakClient", return_value=mock_client + ), patch( + "hhd.plugins.cooling_dock.base.asyncio.sleep", side_effect=track_sleep + ): + await p._connect_and_sync() + # After SYNC_RETRY_MAX errors, the loop breaks and disconnects. + # Then RECONNECT_DELAY 1-second sleeps should follow. + one_second_sleeps = sum(1 for t in sleep_calls if t == 1) + self.assertGreaterEqual(one_second_sleeps, RECONNECT_DELAY) + + +class ChunkedWriteProtocolTest(unittest.TestCase): + """Tests for the chunked write protocol (PR #321).""" + + def test_build_write_chunks_headers_and_payload(self): + """The state must be split into 3 x 20-byte frames with 0x1C/0x2C/0x3C + headers and payload [0x02] + state[2:59].""" + state = bytearray(range(64)) + chunks = build_write_chunks(state) + + self.assertEqual(len(chunks), 3) + for i, chunk in enumerate(chunks): + self.assertEqual(len(chunk), 20) + self.assertEqual(chunk[0], CHUNK_HEADERS[i]) + + # chunk_1 = [0x1C] + payload[0:19], payload[0] = 0x02 + self.assertEqual(chunks[0][1], WRITE_CMD) + self.assertEqual(chunks[0][2], state[2]) + self.assertEqual(chunks[0][19], state[19]) + + # chunk_2 = [0x2C] + payload[19:38] = state[20:39] + self.assertEqual(chunks[1][1], state[20]) + self.assertEqual(chunks[1][19], state[38]) + + # chunk_3 = [0x3C] + payload[38:57] = state[39:58] + self.assertEqual(chunks[2][1], state[39]) + self.assertEqual(chunks[2][19], state[57]) + + def test_build_write_chunks_carries_mode_byte(self): + """A mode change at state[4] must appear in the first chunk.""" + state = bytearray(64) + state[4] = 0xFE # AUTO + chunks = build_write_chunks(state) + # payload[3] = state[4] -> chunk_1[4] + self.assertEqual(chunks[0][4], 0xFE) + + def test_build_write_chunks_carries_curve(self): + """The fan curve at state[23:41] must appear across the payload.""" + state = bytearray(64) + for i in range(23, 41): + state[i] = i + chunks = build_write_chunks(state) + # payload[k] = state[k+1]; state[23] = payload[22]. + # chunk_2 = [0x2C] + payload[19:38] -> payload[22] = chunk_2[4] + self.assertEqual(chunks[1][4], 23) + # state[40] = payload[39]; chunk_3 = [0x3C] + payload[38:57] + # -> payload[39] = chunk_3[2] + self.assertEqual(chunks[2][2], 40) + + +class WriteStateTest(unittest.IsolatedAsyncioTestCase): + """Tests for the plugin's chunked write path.""" + + async def test_write_state_sends_three_chunks(self): + """_write_state must send 3 sequential chunked writes, not one + single 64-byte write.""" + p = CoolingDockPlugin() + mock_client = MagicMock() + mock_client.write_gatt_char = AsyncMock() + + state = bytearray(64) + state[4] = 0x03 + + with patch( + "hhd.plugins.cooling_dock.base.asyncio.sleep", new_callable=AsyncMock + ): + await p._write_state(mock_client, state) + + self.assertEqual(mock_client.write_gatt_char.call_count, 3) + chunks = [c.args[1] for c in mock_client.write_gatt_char.call_args_list] + self.assertEqual([c[0] for c in chunks], list(CHUNK_HEADERS)) + for chunk in chunks: + self.assertEqual(len(chunk), 20) + + async def test_write_state_retries_on_error(self): + """A transient write error should be retried up to WRITE_RETRY_MAX + times before raising.""" + p = CoolingDockPlugin() + mock_client = MagicMock() + mock_client.write_gatt_char = AsyncMock( + side_effect=Exception("In Progress") + ) + + with patch( + "hhd.plugins.cooling_dock.base.asyncio.sleep", new_callable=AsyncMock + ): + with self.assertRaises(Exception): + await p._write_state(mock_client, bytearray(64)) + + # Each attempt fails on the first chunk, so one write per attempt. + self.assertEqual( + mock_client.write_gatt_char.call_count, WRITE_RETRY_MAX + ) + + async def test_write_state_succeeds_after_retry(self): + """If the first attempt fails but a later one succeeds, _write_state + should not raise.""" + p = CoolingDockPlugin() + mock_client = MagicMock() + # First chunk write fails once, then succeeds + calls = 0 + + async def flaky_write(*args, **kwargs): + nonlocal calls + calls += 1 + if calls == 1: + raise Exception("In Progress") + + mock_client.write_gatt_char = AsyncMock(side_effect=flaky_write) + + with patch( + "hhd.plugins.cooling_dock.base.asyncio.sleep", new_callable=AsyncMock + ): + await p._write_state(mock_client, bytearray(64)) + + # 1 failed chunk (attempt 1) + 3 successful chunks (attempt 2) = 4 + self.assertEqual(mock_client.write_gatt_char.call_count, 4) + + +class DockDropdownTest(unittest.IsolatedAsyncioTestCase): + """Tests for populating the 'Dock Device' dropdown.""" + + async def test_connect_adds_mac_to_dropdown(self): + """After a successful auto-connect, the dock's MAC must appear in + the 'Dock Device' dropdown options.""" + p = CoolingDockPlugin() + p.running = True + conf = make_dock_conf() + p.update(conf) + p.emit = MagicMock() + + mock_client = MagicMock() + mock_client.is_connected = True + mock_client.connect = AsyncMock() + mock_client.disconnect = AsyncMock() + mock_client.read_gatt_char = AsyncMock( + return_value=b"\x00" * 64 + ) + mock_client.write_gatt_char = AsyncMock() + + with patch.object( + p, "_find_dock", new_callable=AsyncMock, + return_value=make_dock_device(), + ), patch( + "hhd.plugins.cooling_dock.base.BleakClient", return_value=mock_client + ), patch( + "hhd.plugins.cooling_dock.base.asyncio.sleep", new_callable=AsyncMock + ): + # Stop the loop after the first sync cycle + original_sleep = asyncio.sleep + + async def stop_sleep(t): + p.running = False + + with patch( + "hhd.plugins.cooling_dock.base.asyncio.sleep", + side_effect=stop_sleep, + ): + await p._connect_and_sync() + + self.assertIn("AA:BB:CC:DD:EE:FF", p._discovered_macs) + # The MAC must be registered WITHOUT triggering a settings reload. + # The old code emitted {"type": "settings"} here, which caused a + # full settings reload + SMU re-apply on every connect cycle. + settings_calls = [ + c for c in p.emit.call_args_list + if c == unittest.mock.call({"type": "settings"}) + ] + self.assertEqual( + len(settings_calls), 0, + "Settings emit should NOT fire on MAC registration (causes reload storm)", + ) + + def test_settings_includes_connected_mac(self): + """settings() must include the connected MAC in the dropdown even if + it was never scanned.""" + p = CoolingDockPlugin() + p._mac_address = "AA:BB:CC:DD:EE:FF" + p._discovered_macs = {"": "Auto-detect"} + + base = p.settings() + opts = base["cooling_dock"]["dock"]["children"]["mac_address"]["options"] + self.assertIn("AA:BB:CC:DD:EE:FF", opts) + + +class BluezConnectedDockTest(unittest.TestCase): + """Tests for finding a connected-but-not-advertising dock via BlueZ.""" + + def test_finds_connected_dock(self): + """bluetoothctl devices + info should reveal a connected dock that + is not advertising.""" + p = CoolingDockPlugin() + devices_out = ( + "Device AA:BB:CC:DD:EE:FF CoolingSystem_ONEC1\n" + "Device 11:22:33:44:55:66 SomeOtherDevice\n" + ) + info_out = ( + "Device AA:BB:CC:DD:EE:FF\n" + "\tName: CoolingSystem_ONEC1\n" + "\tConnected: yes\n" + ) + with patch( + "subprocess.run", + side_effect=[ + MagicMock(stdout=devices_out), + MagicMock(stdout=info_out), + ], + ), patch( + "dbus.SystemBus", side_effect=Exception("no bus in test") + ): + result = p._find_connected_dock_via_bluez() + self.assertEqual(result.address, "AA:BB:CC:DD:EE:FF") + + def test_ignores_disconnected_dock(self): + """A known but disconnected dock should not be returned.""" + p = CoolingDockPlugin() + devices_out = "Device AA:BB:CC:DD:EE:FF CoolingSystem_ONEC1\n" + info_out = ( + "Device AA:BB:CC:DD:EE:FF\n" + "\tName: CoolingSystem_ONEC1\n" + "\tConnected: no\n" + ) + with patch( + "subprocess.run", + side_effect=[ + MagicMock(stdout=devices_out), + MagicMock(stdout=info_out), + ], + ), patch( + "dbus.SystemBus", side_effect=Exception("no bus in test") + ): + result = p._find_connected_dock_via_bluez() + self.assertIsNone(result) + + def test_respects_target_mac(self): + """When a target MAC is given, only that dock should be returned.""" + p = CoolingDockPlugin() + devices_out = ( + "Device AA:BB:CC:DD:EE:FF CoolingSystem_ONEC1\n" + "Device 11:22:33:44:55:66 CoolingSystem_ONEC1\n" + ) + info_out = ( + "Device 11:22:33:44:55:66\n" + "\tName: CoolingSystem_ONEC1\n" + "\tConnected: yes\n" + ) + with patch( + "subprocess.run", + side_effect=[ + MagicMock(stdout=devices_out), + MagicMock(stdout=info_out), + ], + ), patch( + "dbus.SystemBus", side_effect=Exception("no bus in test") + ): + result = p._find_connected_dock_via_bluez("11:22:33:44:55:66") + self.assertEqual(result.address, "11:22:33:44:55:66") + + +class WriteOnChangeTest(unittest.IsolatedAsyncioTestCase): + """Tests for the write-only-on-change sync behavior.""" + + async def test_writes_only_on_change(self): + """The sync loop should write when the target changes, then skip + writes while the target is unchanged.""" + p = CoolingDockPlugin() + p.running = True + conf = make_dock_conf() + p.update(conf) + + mock_client = MagicMock() + mock_client.is_connected = True + mock_client.connect = AsyncMock() + mock_client.disconnect = AsyncMock() + mock_client.read_gatt_char = AsyncMock(return_value=b"\x00" * 64) + mock_client.write_gatt_char = AsyncMock() + + original_sleep = asyncio.sleep + + async def limited_sleep(t): + if mock_client.read_gatt_char.call_count > 4: + p.running = False + await original_sleep(0) + + with patch.object( + p, "_find_dock", new_callable=AsyncMock, return_value=make_dock_device() + ), patch( + "hhd.plugins.cooling_dock.base.BleakClient", return_value=mock_client + ), patch( + "hhd.plugins.cooling_dock.base.asyncio.sleep", side_effect=limited_sleep + ): + await p._connect_and_sync() + + # 5 reads, but only 1 write (first cycle; target unchanged after) + self.assertEqual(mock_client.read_gatt_char.call_count, 5) + self.assertEqual(mock_client.write_gatt_char.call_count, 3) + + async def test_writes_again_when_mode_changes(self): + """Changing the mode should trigger a new write.""" + p = CoolingDockPlugin() + p.running = True + conf = make_dock_conf() + p.update(conf) + + mock_client = MagicMock() + mock_client.is_connected = True + mock_client.connect = AsyncMock() + mock_client.disconnect = AsyncMock() + mock_client.read_gatt_char = AsyncMock(return_value=b"\x00" * 64) + mock_client.write_gatt_char = AsyncMock() + + original_sleep = asyncio.sleep + read_count = 0 + + async def limited_sleep(t): + nonlocal read_count + if mock_client.read_gatt_char.call_count > 2 and read_count == 0: + read_count = 1 + # Change the mode mid-loop to trigger a new write + with p.conf_lock: + p.mode = "3" + if mock_client.read_gatt_char.call_count > 5: + p.running = False + await original_sleep(0) + + with patch.object( + p, "_find_dock", new_callable=AsyncMock, return_value=make_dock_device() + ), patch( + "hhd.plugins.cooling_dock.base.BleakClient", return_value=mock_client + ), patch( + "hhd.plugins.cooling_dock.base.asyncio.sleep", side_effect=limited_sleep + ): + await p._connect_and_sync() + + # Initial write + write after mode change = 2 writes (6 chunks) + self.assertEqual(mock_client.write_gatt_char.call_count, 6) + + +class DisconnectGraceTest(unittest.TestCase): + """Tests for the dock_running grace period (transient BLE flaps must + not flip dock_running, which would make the adjustor re-apply TDP and + cause SMU/ACPI spam + fan cycling).""" + + def test_does_not_publish_when_recently_connected(self): + p = CoolingDockPlugin() + conf = make_dock_conf() + p.update(conf) + p._dock_running = True + p._last_connected_time = time.time() # connected just now + p._status = "Connected" + conf["cooling_dock.dock_running"] = True + conf.updated = False + + p._publish_disconnected_if_stale() + + # No publish: dock_running stays True, config not dirtied + self.assertEqual(conf["cooling_dock.dock_running"].to(bool), True) + self.assertFalse(conf.updated) + + def test_publishes_after_grace_period(self): + p = CoolingDockPlugin() + conf = make_dock_conf() + p.update(conf) + conf.updated = False + p._dock_running = True + p._last_connected_time = time.time() - DOCK_RUNNING_GRACE - 1 + p._status = "Connected" + conf["cooling_dock.dock_running"] = True + + p._publish_disconnected_if_stale() + + self.assertEqual(conf["cooling_dock.dock_running"].to(bool), False) + self.assertEqual(conf["cooling_dock.dock.status"].to(str), "Disconnected") + self.assertTrue(conf.updated) + + +class ForgetDockTest(unittest.TestCase): + """The 'Forget Dock' action must also unpair from BlueZ, otherwise the + Trusted dock stays connected and fan control keeps working.""" + + def test_forget_clears_mac_and_unpairs_bluez(self): + p = CoolingDockPlugin() + conf = make_dock_conf() + conf["cooling_dock.dock.mac_address"] = "AA:BB:CC:DD:EE:FF" + p.update(conf) + + with patch.object(p, "_forget_bluez_device") as mock_forget: + conf["cooling_dock.dock.forget_dock"] = True + p.update(conf) + + mock_forget.assert_called_once() + self.assertEqual(conf["cooling_dock.dock.mac_address"].to(str), "") + self.assertEqual(conf["cooling_dock.dock.forget_dock"].to(bool), False) + + def test_forget_bluez_device_removes_from_adapter(self): + p = CoolingDockPlugin() + p._mac_address = "AA:BB:CC:DD:EE:FF" + + objects = { + "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF": { + "org.bluez.Device1": {"Address": "AA:BB:CC:DD:EE:FF"} + } + } + mock_iface = MagicMock() + mock_iface.GetManagedObjects.return_value = objects + + with patch("dbus.Interface", return_value=mock_iface), patch( + "dbus.SystemBus" + ): + p._forget_bluez_device() + + mock_iface.RemoveDevice.assert_called_once_with( + "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF" + ) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_cooling_dock_extended.py b/tests/test_cooling_dock_extended.py new file mode 100644 index 000000000..2ae54e213 --- /dev/null +++ b/tests/test_cooling_dock_extended.py @@ -0,0 +1,110 @@ +import unittest +from unittest.mock import patch + +from hhd.plugins.cooling_dock.base import ( + fan_pct_for_temp, + get_cpu_temp, +) +from hhd.plugins.cooling_dock.protocol import ( + WRITE_CMD, + CoolingStatus, + DockMode, +) + + +class CoolingStatusProtocolTest(unittest.TestCase): + def test_from_bytes_short_raises_value_error(self): + with self.assertRaises(ValueError): + CoolingStatus.from_bytes(b"\x00" * 40) + + def test_from_bytes_full_parsing(self): + raw = bytearray(64) + raw[0] = 0xC1 + raw[1] = 0x10 + raw[2] = 6 # version + raw[4] = int(DockMode.LEVEL_3) + raw[5] = 75 # fan % + raw[6] = 0x08 # fan speed rpm hi + raw[7] = 0xD0 # fan speed rpm lo = 2256 + raw[8] = 50 # pump % + raw[9] = 0x06 + raw[10] = 0x30 # pump rpm = 1584 + raw[11] = 0x01 + raw[12] = 0xF4 # water flow = 500 + raw[13] = 25 # in temp + raw[14] = 28 # out temp + raw[15] = 0x01 # status flag + raw[16] = 0x02 # rgb mode + raw[17] = 0x01 # rgb enable + raw[19] = 0x04 # rgb level + raw[20] = 255 # r + raw[21] = 87 # g + raw[22] = 34 # b + # 9 curve pairs (fan%, temp) + for i in range(9): + raw[23 + i * 2] = 10 * (i + 1) + raw[23 + i * 2 + 1] = 30 + 5 * i + + status = CoolingStatus.from_bytes(raw) + self.assertEqual(status.version, 6) + self.assertEqual(status.mode, int(DockMode.LEVEL_3)) + self.assertEqual(status.fan_speed_percent, 75) + self.assertEqual(status.fan_speed, 2256) + self.assertEqual(status.pump_speed_percent, 50) + self.assertEqual(status.pump_speed, 1584) + self.assertEqual(status.water_flow, 500) + self.assertEqual(status.in_water_temp, 25) + self.assertEqual(status.out_water_temp, 28) + self.assertEqual(status.rgb_mode, 2) + self.assertTrue(status.rgb_enable) + self.assertEqual(status.rgb_light_level, 4) + self.assertEqual((status.rgb_r, status.rgb_g, status.rgb_b), (255, 87, 34)) + self.assertEqual(len(status.fan_curve), 9) + self.assertEqual(status.fan_curve[0], (10, 30)) + + def test_to_write_bytes_preserves_readonly_fields(self): + current = bytearray(64) + current[2] = 5 + current[5] = 80 # dock reports 80% fan + current[8] = 60 # dock reports 60% pump + current[14] = 35 + + status = CoolingStatus( + version=5, + mode=int(DockMode.AUTO), + rgb_enable=True, + rgb_mode=1, + rgb_light_level=3, + ) + out = status.to_write_bytes(current) + self.assertEqual(out[1], WRITE_CMD) + self.assertEqual(out[4], int(DockMode.AUTO)) + # Read-only fields must NOT be overwritten + self.assertEqual(out[5], 80) + self.assertEqual(out[8], 60) + self.assertEqual(out[14], 35) + + +class FanCurveCalculationTest(unittest.TestCase): + def test_fan_pct_interpolation(self): + curve = [(0, 40), (30, 50), (50, 60), (70, 70), (85, 80)] + self.assertEqual(fan_pct_for_temp(35, curve), 0) + self.assertEqual(fan_pct_for_temp(40, curve), 0) + self.assertEqual(fan_pct_for_temp(45, curve), 15) # halfway between 0 and 30 + self.assertEqual(fan_pct_for_temp(50, curve), 30) + self.assertEqual(fan_pct_for_temp(65, curve), 60) # halfway between 50 and 70 + self.assertEqual(fan_pct_for_temp(80, curve), 85) + self.assertEqual(fan_pct_for_temp(90, curve), 85) + + def test_empty_curve_returns_zero(self): + self.assertEqual(fan_pct_for_temp(50, []), 0) + + +class ExtendedDockPluginTest(unittest.TestCase): + def test_get_cpu_temp_fallback(self): + with patch("os.path.exists", return_value=False): + self.assertEqual(get_cpu_temp(), 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cooling_dock_fixes.py b/tests/test_cooling_dock_fixes.py new file mode 100644 index 000000000..bc1f42f66 --- /dev/null +++ b/tests/test_cooling_dock_fixes.py @@ -0,0 +1,229 @@ +"""Tests for cooling dock reconnect loop fixes. + +Covers: +- _ensure_trusted_bluez() method existence and behavior +- Removal of settings emit calls in dock connection loop to prevent reload storms +- SmuQamPlugin correctly emitting settings on dock/power changes to dynamically update slider range +- TDP clamping works without settings reload +""" + +import time +import unittest +from unittest.mock import MagicMock, patch, call + +from hhd.plugins.cooling_dock.base import CoolingDockPlugin +from hhd.plugins.conf import Config + + +def make_dock_conf(**overrides) -> Config: + base = { + "cooling_dock.dock": { + "enabled": True, + "mode": "auto", + "fan_curve": { + "t1": 40, "f1": 0, + "t2": 50, "f2": 30, + "t3": 60, "f3": 50, + "t4": 70, "f4": 70, + "t5": 80, "f5": 85, + }, + "rgb": {"enable": True, "mode": 1, "level": 3}, + "mac_address": "", + }, + "cooling_dock.dock_running": False, + "cooling_dock.dock.status": None, + "cooling_dock.dock.fan_progress": None, + } + base.update(overrides) + return Config(base) + +class TestNoSettingsEmitOnConnect(unittest.TestCase): + """Verify the dock plugin does NOT emit {"type": "settings"} when + discovering new MACs, which was causing the reload storm.""" + + def test_no_emit_on_new_mac_registration(self): + """When a new MAC is registered on connect, no settings emit should + fire. The old code emitted settings here, causing a full reload + + SMU re-apply on every connect.""" + p = CoolingDockPlugin() + emit = MagicMock() + p.emit = emit + conf = make_dock_conf() + p.update(conf) + emit.reset_mock() # clear the initial settings emit from first update + + # Simulate what _connect_and_sync does when it finds a new MAC: + # it registers the MAC in _discovered_macs. + addr = "C8:17:17:F5:C8:91" + with p.conf_lock: + p._discovered_macs[addr] = f"CoolingSystem_ONEC1 ({addr})" + + # The emit should NOT have been called with {"type": "settings"} + settings_calls = [ + c for c in emit.call_args_list + if c == call({"type": "settings"}) + ] + self.assertEqual( + len(settings_calls), 0, + "emit({'type': 'settings'}) should not be called on MAC registration", + ) + + def test_no_emit_on_scan_completion(self): + """After a manual scan populates _discovered_macs, no settings emit + should fire.""" + p = CoolingDockPlugin() + emit = MagicMock() + p.emit = emit + conf = make_dock_conf() + p.update(conf) + emit.reset_mock() # clear the initial settings emit from first update + + # Simulate scan completion + with p.conf_lock: + p._discovered_macs = { + "": "Auto-detect", + "AA:BB:CC:DD:EE:FF": "CoolingSystem_ONEC1 (AA:BB:CC:DD:EE:FF)", + } + + settings_calls = [ + c for c in emit.call_args_list + if c == call({"type": "settings"}) + ] + self.assertEqual( + len(settings_calls), 0, + "emit({'type': 'settings'}) should not be called after scan", + ) + + +class ConnectAndSyncIntegrationTest(unittest.TestCase): + """Integration test exercising the full _connect_and_sync lifecycle + with a fake BleakClient that simulates real BLE behavior sequences.""" + + def _make_plugin(self): + p = CoolingDockPlugin() + p.emit = MagicMock() + p.running = True + p.enabled = True + p._mac_address = "C8:17:17:F5:C8:91" + p.mode = "auto" + p.fan_curve = [(0, 40), (30, 50), (50, 60), (70, 70), (85, 80)] + p.rgb_enable = True + p.rgb_mode = 1 + p.rgb_level = 3 + return p + + def _make_gatt_response(self, fan_speed=1200, fan_pct=50): + """Build a minimal 64-byte GATT response mimicking the dock.""" + data = bytearray(64) + data[4] = 1 # mode = auto + data[5] = fan_pct + data[6] = (fan_speed >> 8) & 0xFF + data[7] = fan_speed & 0xFF + return bytes(data) + + @patch("hhd.plugins.cooling_dock.base.get_cpu_temp", return_value=55.0) + @patch("hhd.plugins.cooling_dock.base.fan_pct_for_temp", return_value=60) + def test_full_sync_cycle_with_disconnect(self, mock_fan, mock_temp): + """Simulate: discover -> connect -> 2 successful reads -> + disconnect callback fires -> sync loop exits cleanly.""" + import asyncio + from unittest.mock import AsyncMock + + p = self._make_plugin() + gatt_data = self._make_gatt_response() + + fake_client = MagicMock() + fake_client.is_connected = True + fake_client.connect = AsyncMock() + fake_client.write_gatt_char = AsyncMock() + fake_client.disconnect = AsyncMock() + + read_count = 0 + disconnect_cb = None + + async def fake_read(char_uuid, **kwargs): + nonlocal read_count + read_count += 1 + if read_count > 2: + fake_client.is_connected = False + if disconnect_cb: + disconnect_cb(fake_client) + raise Exception("BLE disconnected") + return gatt_data + + fake_client.read_gatt_char = fake_read + + fake_device = MagicMock() + fake_device.address = "C8:17:17:F5:C8:91" + + async def fake_find_dock(): + return fake_device + + with ( + patch.object(p, "_find_dock", side_effect=fake_find_dock), + patch("hhd.plugins.cooling_dock.base.BleakClient") as mock_bleak_cls, + patch("asyncio.sleep", new_callable=AsyncMock), + ): + def capture_client(device, timeout=15, disconnected_callback=None): + nonlocal disconnect_cb + disconnect_cb = disconnected_callback + return fake_client + mock_bleak_cls.side_effect = capture_client + + asyncio.run(p._connect_and_sync()) + + self.assertGreaterEqual(read_count, 2, "Should complete at least 2 GATT reads") + self.assertTrue(p._dock_running or p._last_connected_time > 0, + "Dock should have been marked running during sync") + fake_client.disconnect.assert_called() + + @patch("hhd.plugins.cooling_dock.base.get_cpu_temp", return_value=55.0) + @patch("hhd.plugins.cooling_dock.base.fan_pct_for_temp", return_value=60) + def test_transient_timeouts_recover(self, mock_fan, mock_temp): + """Simulate: 2 GATT timeouts followed by recovery. The sync loop + should NOT break because SYNC_RETRY_MAX=3.""" + import asyncio + from unittest.mock import AsyncMock + + p = self._make_plugin() + gatt_data = self._make_gatt_response() + + fake_client = MagicMock() + fake_client.is_connected = True + fake_client.connect = AsyncMock() + fake_client.write_gatt_char = AsyncMock() + fake_client.disconnect = AsyncMock() + + read_count = 0 + + async def fake_read(char_uuid, **kwargs): + nonlocal read_count + read_count += 1 + if read_count <= 2: + raise asyncio.TimeoutError("simulated GATT timeout") + if read_count == 3: + return gatt_data + p.running = False + return gatt_data + + fake_client.read_gatt_char = fake_read + + fake_device = MagicMock() + fake_device.address = "C8:17:17:F5:C8:91" + + async def fake_find_dock(): + return fake_device + + with ( + patch.object(p, "_find_dock", side_effect=fake_find_dock), + patch("hhd.plugins.cooling_dock.base.BleakClient") as mock_bleak_cls, + patch("asyncio.sleep", new_callable=AsyncMock), + ): + mock_bleak_cls.return_value = fake_client + asyncio.run(p._connect_and_sync()) + + self.assertGreaterEqual(read_count, 3, "Should recover after transient timeouts") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cooling_dock_protocol.py b/tests/test_cooling_dock_protocol.py new file mode 100644 index 000000000..4686cd956 --- /dev/null +++ b/tests/test_cooling_dock_protocol.py @@ -0,0 +1,340 @@ +"""Protocol-level tests for the CoolingSystem_ONEC1 BLE dock. + +Covers the byte-level wire format (CoolingStatus.From/.Fill recovered from +CompatLayerCT.exe JIT disassembly), the 3x20-byte chunked write framing, +and the fan curve interpolation helper. +""" + +import unittest +from unittest.mock import mock_open, patch + +from hhd.plugins.cooling_dock.base import fan_pct_for_temp, get_cpu_temp +from hhd.plugins.cooling_dock.protocol import ( + CHUNK_DELAY_S, + CHUNK_HEADERS, + CHUNK_SIZE, + READ_CMD, + SERVICE_UUID, + TOTAL_BYTES, + WRITE_CMD, + WRITE_PAYLOAD_SIZE, + CoolingStatus, + DockMode, + build_write_chunks, +) + + +def _sample_status_bytes() -> bytearray: + """Build a realistic 64-byte GATT notification frame.""" + data = bytearray(TOTAL_BYTES) + data[0] = 0xA5 # sync/header byte + data[1] = READ_CMD + data[2] = 3 # version + data[4] = DockMode.AUTO + data[5] = 55 # fan_speed_percent + data[6] = 0x04 # fan_speed hi (0x04B0 = 1200 RPM) + data[7] = 0xB0 # fan_speed lo + data[8] = 80 # pump_speed_percent + data[9] = 0x0E # pump_speed hi (0x0E74 = 3700 RPM) + data[10] = 0x74 # pump_speed lo + data[11] = 0x01 # water_flow hi (0x0102 = 258) + data[12] = 0x02 # water_flow lo + data[13] = 32 # in_water_temp + data[14] = 35 # out_water_temp + data[15] = 0x07 # status_flag + data[16] = 0x02 # rgb_mode + data[17] = 1 # rgb_enable + data[19] = 3 # rgb_light_level + data[20] = 255 # r + data[21] = 128 # g + data[22] = 0 # b + # Fan curve: 9 points starting at offset 23 + curve = [(30, 40), (40, 50), (50, 60), (60, 70), (70, 80), + (80, 90), (90, 100), (100, 100), (110, 100)] + for i, (f, t) in enumerate(curve): + data[23 + i * 2] = f + data[24 + i * 2] = t + return data + + +class DockModeTest(unittest.TestCase): + def test_mode_values_match_wire_protocol(self): + self.assertEqual(DockMode.STOPPED, 0x00) + self.assertEqual(DockMode.LEVEL_1, 0x01) + self.assertEqual(DockMode.LEVEL_5, 0x05) + self.assertEqual(DockMode.AUTO, 0xFE) + self.assertEqual(DockMode.MANUAL, 0xFF) + + def test_modes_are_ints(self): + for mode in DockMode: + self.assertIsInstance(mode, int) + + +class GattConstantsTest(unittest.TestCase): + def test_uuids(self): + self.assertEqual(SERVICE_UUID, "0000ffe0-0000-1000-8000-00805f9b34fb") + from hhd.plugins.cooling_dock.protocol import CHAR_UUID, NOTIFY_UUID + self.assertEqual(CHAR_UUID, "0000ffe1-0000-1000-8000-00805f9b34fb") + self.assertEqual(NOTIFY_UUID, "0000ffe4-0000-1000-8000-00805f9b34fb") + + def test_chunk_framing_constants(self): + self.assertEqual(CHUNK_HEADERS, (0x1C, 0x2C, 0x3C)) + self.assertEqual(CHUNK_SIZE, 19) + self.assertEqual(WRITE_PAYLOAD_SIZE, 58) + self.assertEqual(3 * CHUNK_SIZE, WRITE_PAYLOAD_SIZE - 1) + self.assertGreater(CHUNK_DELAY_S, 0) + + +class CoolingStatusFromBytesTest(unittest.TestCase): + def test_parses_all_fields(self): + s = CoolingStatus.from_bytes(bytes(_sample_status_bytes())) + self.assertEqual(s.version, 3) + self.assertEqual(s.mode, DockMode.AUTO) + self.assertEqual(s.fan_speed_percent, 55) + self.assertEqual(s.fan_speed, 1200) + self.assertEqual(s.pump_speed_percent, 80) + self.assertEqual(s.pump_speed, 3700) + self.assertEqual(s.water_flow, 258) + self.assertEqual(s.in_water_temp, 32) + self.assertEqual(s.out_water_temp, 35) + self.assertEqual(s.status_flag, 0x07) + self.assertEqual(s.rgb_mode, 0x02) + self.assertTrue(s.rgb_enable) + self.assertEqual(s.rgb_light_level, 3) + self.assertEqual((s.rgb_r, s.rgb_g, s.rgb_b), (255, 128, 0)) + self.assertEqual(len(s.fan_curve), 9) + self.assertEqual(s.fan_curve[0], (30, 40)) + self.assertEqual(s.fan_curve[8], (110, 100)) + + def test_rgb_enable_is_strict_one(self): + data = _sample_status_bytes() + data[17] = 2 # any value other than 1 means disabled + s = CoolingStatus.from_bytes(bytes(data)) + self.assertFalse(s.rgb_enable) + + def test_short_frame_raises(self): + with self.assertRaises(ValueError): + CoolingStatus.from_bytes(bytes(40)) + + def test_minimum_length_accepted(self): + s = CoolingStatus.from_bytes(bytes(41)) + self.assertEqual(s.version, 0) + self.assertEqual(s.fan_curve[-1], (0, 0)) + + def test_minimum_length_frame_parses_full_curve(self): + """41 bytes is exactly enough for the header + 9 curve points.""" + data = _sample_status_bytes() + s = CoolingStatus.from_bytes(bytes(data[:41])) + self.assertEqual(len(s.fan_curve), 9) + self.assertEqual(s.fan_curve[0], (30, 40)) + self.assertEqual(s.fan_curve[-1], (110, 100)) + + +class CoolingStatusWriteTest(unittest.TestCase): + def test_write_sets_cmd_and_preserves_version(self): + current = _sample_status_bytes() + s = CoolingStatus.from_bytes(bytes(current)) + s.mode = DockMode.LEVEL_3 + out = s.to_write_bytes(current) + self.assertEqual(out[1], WRITE_CMD) + self.assertEqual(out[2], 3) # version preserved + self.assertEqual(out[4], DockMode.LEVEL_3) + + def test_write_zero_version_uses_current(self): + current = _sample_status_bytes() + s = CoolingStatus.from_bytes(bytes(current)) + s.version = 0 + out = s.to_write_bytes(current) + self.assertEqual(out[2], 3) # falls back to current version + + def test_read_only_bytes_not_touched_by_fields(self): + """fan/pump percent+speed (5..10) come from the current snapshot.""" + current = _sample_status_bytes() + s = CoolingStatus.from_bytes(bytes(current)) + s.mode = DockMode.MANUAL + out = s.to_write_bytes(current) + self.assertEqual(out[5], current[5]) + self.assertEqual(out[6], current[6]) + self.assertEqual(out[7], current[7]) + self.assertEqual(out[8], current[8]) + + def test_roundtrip_from_write_to_parse(self): + current = _sample_status_bytes() + s = CoolingStatus.from_bytes(bytes(current)) + s.mode = DockMode.LEVEL_2 + s.rgb_r = 10 + s.rgb_g = 20 + s.rgb_b = 30 + out = s.to_write_bytes(current) + parsed = CoolingStatus.from_bytes(bytes(out)) + self.assertEqual(parsed.mode, DockMode.LEVEL_2) + self.assertEqual((parsed.rgb_r, parsed.rgb_g, parsed.rgb_b), (10, 20, 30)) + self.assertEqual(parsed.fan_curve, s.fan_curve) + + +class BuildWriteChunksTest(unittest.TestCase): + def test_produces_three_twenty_byte_chunks(self): + state = bytes(_sample_status_bytes()) + chunks = build_write_chunks(state) + self.assertEqual(len(chunks), 3) + for c in chunks: + self.assertEqual(len(c), CHUNK_SIZE + 1) # header + payload + + def test_chunk_headers_in_order(self): + chunks = build_write_chunks(bytes(_sample_status_bytes())) + self.assertEqual([c[0] for c in chunks], list(CHUNK_HEADERS)) + + def test_payload_reassembly_matches_state(self): + """payload == [0x02] + state[2:59]; verify per-chunk.""" + state = bytes(_sample_status_bytes()) + chunks = build_write_chunks(state) + payload = bytearray(WRITE_PAYLOAD_SIZE) + payload[0] = WRITE_CMD + payload[1:] = state[2:59] + pos = 0 + for c in chunks: + body = c[1:] + self.assertEqual(body, bytes(payload[pos:pos + CHUNK_SIZE])) + pos += CHUNK_SIZE + + def test_modified_field_lands_in_correct_chunk(self): + """mode is state[4] -> payload[3] -> chunk 0.""" + state = bytearray(_sample_status_bytes()) + state[4] = DockMode.LEVEL_5 + chunks = build_write_chunks(bytes(state)) + self.assertEqual(chunks[0][1 + 3], DockMode.LEVEL_5) + + def test_rgb_bytes_land_in_chunk_1(self): + """rgb fields are state[19..22] -> payload[18..21]: chunk 0 [18], + chunk 1 [0..2].""" + state = bytearray(_sample_status_bytes()) + state[19] = 7 + state[22] = 9 + chunks = build_write_chunks(bytes(state)) + self.assertEqual(chunks[0][1 + 18], 7) + self.assertEqual(chunks[1][1 + 2], 9) + + +class FanCurveTest(unittest.TestCase): + # Curve points are (fan_pct, temp): 0% @40C, 30% @50C, ... 85% @80C + CURVE = [(0, 40), (30, 50), (50, 60), (70, 70), (85, 80)] + + def test_empty_curve_returns_zero(self): + self.assertEqual(fan_pct_for_temp(50, []), 0) + + def test_below_first_point_clamps_to_first_fan(self): + self.assertEqual(fan_pct_for_temp(-10, self.CURVE), 0) + self.assertEqual(fan_pct_for_temp(40, self.CURVE), 0) + + def test_above_last_point_clamps_to_last_fan(self): + self.assertEqual(fan_pct_for_temp(200, self.CURVE), 85) + + def test_exact_point_returns_exact_value(self): + self.assertEqual(fan_pct_for_temp(50, self.CURVE), 30) + self.assertEqual(fan_pct_for_temp(60, self.CURVE), 50) + self.assertEqual(fan_pct_for_temp(85, self.CURVE), 85) + + def test_interpolates_linearly_between_points(self): + # Between (0,40) and (30,50): midpoint 45C -> 15% + self.assertEqual(fan_pct_for_temp(45, self.CURVE), 15) + # Between (30,50) and (50,60): midpoint 55C -> 40% + self.assertEqual(fan_pct_for_temp(55, self.CURVE), 40) + # Between (70,70) and (85,80): midpoint 75C -> 77.5% -> truncates to 77 + self.assertEqual(fan_pct_for_temp(75, self.CURVE), 77) + + def test_unsorted_curve_is_sorted(self): + shuffled = [(85, 80), (0, 40), (70, 70), (50, 60), (30, 50)] + self.assertEqual(fan_pct_for_temp(45, shuffled), 15) + + def test_duplicate_temps_do_not_crash(self): + curve = [(10, 40), (30, 50), (60, 50), (90, 90)] + # Boundary at the duplicated temp resolves via the earlier segment + self.assertEqual(fan_pct_for_temp(50, curve), 30) + # Just past it interpolates toward the next point: 60 + (5/40)*30 + self.assertEqual(fan_pct_for_temp(55, curve), 63) + + def test_single_point_curve_is_constant(self): + self.assertEqual(fan_pct_for_temp(10, [(100, 60)]), 100) + self.assertEqual(fan_pct_for_temp(90, [(100, 60)]), 100) + + +class GetCpuTempTest(unittest.TestCase): + def _fake_hwmon(self, entries): + """entries: dict of hwmon dir name -> (name, {tempN_input: millidegrees})""" + import os + + def fake_join(*parts): + return "/".join(parts) + + def fake_listdir(path): + if path == "/sys/class/hwmon": + return list(entries.keys()) + if os.path.basename(path) in entries: + name, temps = entries[os.path.basename(path)] + return list(temps.keys()) + ["name"] + raise FileNotFoundError(path) + + def fake_open(path, mode="r"): + base = os.path.basename(path) + parent = os.path.basename(os.path.dirname(path)) + if parent in entries: + name, temps = entries[parent] + if base == "name": + return mock_open(read_data=name)() + if base in temps: + return mock_open(read_data=str(temps[base]))() + raise FileNotFoundError(path) + + return fake_listdir, fake_open + + def test_reads_highest_k10temp_sensor(self): + listdir, open_fn = self._fake_hwmon({ + "hwmon0": ("k10temp", {"temp1_input": 55000, "temp2_input": 67500}), + "hwmon1": ("nvme", {"temp1_input": 90000}), # ignored device + }) + with patch("os.path.exists", return_value=True), \ + patch("os.listdir", side_effect=listdir), \ + patch("builtins.open", side_effect=open_fn): + self.assertEqual(get_cpu_temp(), 67.5) + + def test_prefers_highest_across_devices(self): + listdir, open_fn = self._fake_hwmon({ + "hwmon0": ("k10temp", {"temp1_input": 55000}), + "hwmon1": ("oxpec", {"temp1_input": 61000}), + }) + with patch("os.path.exists", return_value=True), \ + patch("os.listdir", side_effect=listdir), \ + patch("builtins.open", side_effect=open_fn): + self.assertEqual(get_cpu_temp(), 61.0) + + def test_missing_sysfs_returns_zero(self): + with patch("os.path.exists", return_value=False): + self.assertEqual(get_cpu_temp(), 0.0) + + def test_unreadable_device_is_skipped(self): + import os + + real_listdir = os.listdir + + def listdir(path): + if path == "/sys/class/hwmon": + return ["hwmon0", "hwmon1"] + if path.endswith("hwmon0"): + raise PermissionError(path) + return real_listdir(path) + + def open_fn(path, mode="r"): + if "hwmon1" in path: + if path.endswith("name"): + return mock_open(read_data="k10temp")() + return mock_open(read_data="48000")() + raise FileNotFoundError(path) + + with patch("os.path.exists", return_value=True), \ + patch("os.listdir", side_effect=listdir), \ + patch("builtins.open", side_effect=open_fn): + self.assertEqual(get_cpu_temp(), 48.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_oxp_device_controller.py b/tests/test_oxp_device_controller.py new file mode 100644 index 000000000..56c163044 --- /dev/null +++ b/tests/test_oxp_device_controller.py @@ -0,0 +1,124 @@ +import unittest +from unittest.mock import patch + +from hhd.device.oxp.base import OxpAtKbd +from hhd.device.oxp.const import ( + BTN_MAPPINGS, + CONFS, + get_default_config, +) +from hhd.device.oxp.hid_v1 import ( + gen_rgb_mode as gen_hid1_rgb_mode, +) +from hhd.device.oxp.hid_v1 import ( + gen_rgb_solid as gen_hid1_rgb_solid, +) +from hhd.device.oxp.hid_v1 import ( + gen_vibration, +) +from hhd.device.oxp.hid_v2 import ( + gen_rgb_mode as gen_hid2_rgb_mode, +) +from hhd.device.oxp.hid_v2 import ( + gen_rgb_solid as gen_hid2_rgb_solid, +) +from hhd.device.oxp.serial import ( + gen_brightness as gen_serial_brightness, +) +from hhd.device.oxp.serial import ( + gen_cmd as gen_serial_cmd, +) +from hhd.device.oxp.serial import ( + gen_rgb_mode as gen_serial_rgb_mode, +) +from hhd.device.oxp.serial import ( + gen_rgb_solid as gen_serial_rgb_solid, +) + + +class OxpDeviceConfigTest(unittest.TestCase): + def test_superx_and_apex_registered(self): + self.assertIn("ONEXPLAYER SUPER X", CONFS) + self.assertIn("ONEXPLAYER APEX", CONFS) + superx = CONFS["ONEXPLAYER SUPER X"] + self.assertEqual(superx["name"], "ONEXPLAYER SUPER X") + self.assertEqual(superx["protocol"], "mixed") + self.assertTrue(superx["hrtimer"]) + + def test_default_config_fallback(self): + conf = get_default_config("ONEXPLAYER SUPER X 2", "ONEXPLAYER") + self.assertEqual(conf["name"], "ONEXPLAYER SUPER X 2") + self.assertTrue(conf["untested"]) + self.assertTrue(conf["hrtimer"]) + + +class OxpSerialProtocolTest(unittest.TestCase): + def test_gen_cmd_framing(self): + cmd = gen_serial_cmd(0xFD, [0x00, 0x01], size=64) + self.assertEqual(len(cmd), 64) + self.assertEqual(cmd[0], 0xFD) + self.assertEqual(cmd[1], 0x3F) + self.assertEqual(cmd[2], 0x00) + self.assertEqual(cmd[3], 0x01) + self.assertEqual(cmd[-2], 0x3F) + self.assertEqual(cmd[-1], 0xFD) + + def test_gen_rgb_mode(self): + cmd = gen_serial_rgb_mode("flowing") + self.assertEqual(cmd[0], 0xFD) + self.assertEqual(cmd[3], 0x03) + + def test_gen_rgb_solid(self): + cmd = gen_serial_rgb_solid(255, 128, 64, side=0x00) + self.assertEqual(cmd[0], 0xFD) + self.assertEqual(cmd[2], 0x00) + self.assertEqual(cmd[3], 0xFE) + self.assertEqual(cmd[6], 255) + self.assertEqual(cmd[7], 128) + self.assertEqual(cmd[8], 64) + + def test_gen_brightness(self): + cmd = gen_serial_brightness(0, True, "high") + self.assertEqual(cmd[0], 0xFD) + self.assertEqual(cmd[6], 1) + self.assertEqual(cmd[8], 0x04) + + +class OxpHidProtocolsTest(unittest.TestCase): + def test_hid_v1_rgb_mode(self): + cmd = gen_hid1_rgb_mode("sunset") + self.assertEqual(cmd[0], 0xB8) + self.assertEqual(cmd[1], 0x3F) + self.assertEqual(cmd[3], 0x0B) + + def test_hid_v1_rgb_solid(self): + cmd = gen_hid1_rgb_solid(10, 20, 30, side=0x00) + self.assertEqual(cmd[0], 0xB8) + self.assertEqual(cmd[3], 0xFE) + self.assertEqual(cmd[6], 10) + self.assertEqual(cmd[7], 20) + self.assertEqual(cmd[8], 30) + + def test_hid_v1_vibration(self): + cmd = gen_vibration(5) + self.assertEqual(cmd[0], 0xB3) + self.assertEqual(cmd[1], 0x3F) + + def test_hid_v2_rgb_mode(self): + cmd = gen_hid2_rgb_mode("neon") + self.assertEqual(cmd[0], 0x07) + self.assertEqual(cmd[1], 0xFF) + self.assertEqual(cmd[2], 0x05) + + def test_hid_v2_rgb_solid(self): + cmd = gen_hid2_rgb_solid(100, 150, 200) + self.assertEqual(cmd[0], 0x07) + self.assertEqual(cmd[1], 0xFF) + self.assertEqual(cmd[2], 0xFE) + self.assertEqual(cmd[3], 100) + self.assertEqual(cmd[4], 150) + self.assertEqual(cmd[5], 200) + + +if __name__ == "__main__": + unittest.main() diff --git a/usr/lib/udev/rules.d/83-hhd.rules b/usr/lib/udev/rules.d/83-hhd.rules index ff0150113..cb8013e9a 100644 --- a/usr/lib/udev/rules.d/83-hhd.rules +++ b/usr/lib/udev/rules.d/83-hhd.rules @@ -41,4 +41,11 @@ ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="e310", RUN+="/sbin/modprobe xpad" RU # Banish Ally HID devices to oblivion since they crash SDL/Proton controller handlers SUBSYSTEMS=="usb|hidraw", ATTRS{idVendor}=="0b05", ATTRS{idProduct}=="1b4c", MODE="000", GROUP="root", TAG-="uaccess", RUN+="/bin/chmod 000 /dev/%k" -SUBSYSTEMS=="usb|hidraw", ATTRS{idVendor}=="0b05", ATTRS{idProduct}=="1abe", MODE="000", GROUP="root", TAG-="uaccess", RUN+="/bin/chmod 000 /dev/%k" \ No newline at end of file +SUBSYSTEMS=="usb|hidraw", ATTRS{idVendor}=="0b05", ATTRS{idProduct}=="1abe", MODE="000", GROUP="root", TAG-="uaccess", RUN+="/bin/chmod 000 /dev/%k" + +# Mute spurious Volume Down events from ONEXPLAYER Cooling Dock (Only on SUPER X or APEX) +SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="CoolingSystem*", PROGRAM="/bin/grep -Eiq 'ONEXPLAYER (SUPER X|APEX)' /sys/class/dmi/id/product_name", ATTR{inhibited}="1", ENV{ID_INPUT}="", MODE="000" + +# Block dock hidraw to prevent desktop BT managers from prompting for pairing. +# We only use the GATT service, matching Windows behavior. +KERNEL=="hidraw*", ATTRS{name}=="CoolingSystem*", PROGRAM="/bin/grep -Eiq 'ONEXPLAYER (SUPER X|APEX)' /sys/class/dmi/id/product_name", MODE="000", GROUP="root", TAG-="uaccess"