#!/usr/bin/env python3 """Haptic feedback daemon for Synaptics touchpads with Manual Trigger.""" import fcntl import glob import os import select import signal import struct import sys import time import errno from pathlib import Path VENDOR = "06CB" REPORT_ID = 0x37 DEFAULT_INTENSITY = 100 DEBUG = os.environ.get("DELL_XPS_TOUCHPAD_HAPTICS_DEBUG", "").lower() not in ( "", "0", "false", "no", ) USER_HOME = Path( os.environ.get("DELL_XPS_TOUCHPAD_HAPTICS_HOME", str(Path.home())) ) CONFIG_PATH = USER_HOME / ".config/omarchy/dell-haptic.conf" EVENT_FORMAT = "llHHi" EVENT_SIZE = struct.calcsize(EVENT_FORMAT) EV_KEY = 0x01 BTN_LEFT = 272 BTN_RIGHT = 273 BTN_MIDDLE = 274 BUTTON_NAMES = { BTN_LEFT: "left", BTN_RIGHT: "right", BTN_MIDDLE: "middle", } DEVICE_GONE_ERRNOS = { errno.ENODEV, errno.ENXIO, errno.EIO, } UNSUPPORTED_READBACK_ERRNOS = { getattr(errno, "EREMOTEIO", 121), errno.EINVAL, errno.EOPNOTSUPP, } READBACK_UNSUPPORTED = object() STATE = { "apply_requested": None, "intensity": DEFAULT_INTENSITY, "config_mtime_ns": None, "readback_supported": None, "report": struct.pack("BB", REPORT_ID, DEFAULT_INTENSITY), } class DeviceDisconnected(Exception): pass def HIDIOCSFEATURE(length): return 0xC0000000 | (length << 16) | (ord("H") << 8) | 0x06 def HIDIOCGFEATURE(length): return 0xC0000000 | (length << 16) | (ord("H") << 8) | 0x07 def clamp_intensity(value): return max(0, min(100, value)) def level_for_intensity(intensity): if intensity <= 30: return "low" if intensity < 75: return "mid" return "high" def log(message): print(message, flush=True) def log_debug(message): if DEBUG: log(message) def load_intensity(): try: raw = CONFIG_PATH.read_text(encoding="utf-8") except OSError: return DEFAULT_INTENSITY for line in raw.splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = (part.strip() for part in line.split("=", 1)) if key != "INTENSITY": continue try: return clamp_intensity(int(value)) except ValueError: print( f"Ignoring invalid intensity in {CONFIG_PATH}: {value!r}; using default {DEFAULT_INTENSITY}", file=sys.stderr, flush=True, ) return DEFAULT_INTENSITY return DEFAULT_INTENSITY def config_mtime_ns(): try: return CONFIG_PATH.stat().st_mtime_ns except OSError: return None def refresh_intensity(signum=None, _frame=None, force=False): mtime_ns = config_mtime_ns() previous_intensity = STATE["intensity"] previous_mtime_ns = STATE["config_mtime_ns"] if not force and signum != signal.SIGHUP and mtime_ns == STATE["config_mtime_ns"]: return intensity = load_intensity() STATE["intensity"] = intensity STATE["config_mtime_ns"] = mtime_ns STATE["report"] = struct.pack("BB", REPORT_ID, intensity) if force: reason = "startup" elif signum == signal.SIGHUP: reason = "sighup" else: reason = "config-change" if ( force or signum == signal.SIGHUP or mtime_ns != previous_mtime_ns or intensity != previous_intensity ): log( f"Loaded haptic level={level_for_intensity(intensity)} intensity={intensity} reason={reason} config={CONFIG_PATH}" ) return True return False def request_refresh(signum, _frame): refresh_intensity(signum=signum) STATE["apply_requested"] = "sighup" def send_haptic_report(hidraw_fd, ioctl_req, report): try: fcntl.ioctl(hidraw_fd, ioctl_req, report) return True except OSError as error: if error.errno in DEVICE_GONE_ERRNOS: raise DeviceDisconnected(error) from error print(f"Failed to send haptic report: {error}", file=sys.stderr, flush=True) return False def read_haptic_report(hidraw_fd, ioctl_req): if STATE["readback_supported"] is False: return READBACK_UNSUPPORTED report = bytearray((REPORT_ID, 0)) try: fcntl.ioctl(hidraw_fd, ioctl_req, report, True) except OSError as error: if error.errno in DEVICE_GONE_ERRNOS: raise DeviceDisconnected(error) from error if error.errno in UNSUPPORTED_READBACK_ERRNOS: if STATE["readback_supported"] is not False: log( f"Haptic gain readback is unsupported on this device ({error}); continuing in write-only mode" ) STATE["readback_supported"] = False return READBACK_UNSUPPORTED print(f"Failed to read haptic report: {error}", file=sys.stderr, flush=True) return None STATE["readback_supported"] = True return report[1] def apply_current_report(hidraw_fd, ioctl_set_req, ioctl_get_req, reason): intensity = STATE["intensity"] if not send_haptic_report(hidraw_fd, ioctl_set_req, STATE["report"]): log(f"Failed to apply haptic level={level_for_intensity(intensity)} intensity={intensity} reason={reason}") return False readback = read_haptic_report(hidraw_fd, ioctl_get_req) if readback is READBACK_UNSUPPORTED: log( f"Applied haptic level={level_for_intensity(intensity)} intensity={intensity} reason={reason} readback=unsupported" ) return True if readback is None: log( f"Applied haptic level={level_for_intensity(intensity)} intensity={intensity} reason={reason} readback=unavailable" ) return True readback_level = level_for_intensity(readback) if readback == intensity: log( f"Verified haptic level={readback_level} intensity={intensity} reason={reason} readback={readback}" ) else: log( f"Haptic gain mismatch expected={intensity}({level_for_intensity(intensity)}) readback={readback}({readback_level}) reason={reason}" ) return True def find_hidraw(): for path in sorted(glob.glob("/sys/class/hidraw/hidraw*")): uevent = os.path.join(path, "device", "uevent") try: with open(uevent, encoding="utf-8") as file: content = file.read().upper() if f"0000{VENDOR}" in content: return os.path.join("/dev", os.path.basename(path)) except OSError: continue return None def find_touchpad_event(): for path in sorted(glob.glob("/sys/class/input/event*/device/name")): try: with open(path, encoding="utf-8") as file: name = file.read().strip().upper() if VENDOR in name and "TOUCHPAD" in name: event = path.split("/")[-3] return os.path.join("/dev/input", event) except OSError: continue return None def open_device_pair(): waiting_logged = False while True: hidraw = find_hidraw() event = find_touchpad_event() if not hidraw or not event: if not waiting_logged: log("Waiting for Synaptics haptic touchpad devices to appear") waiting_logged = True time.sleep(1) continue hidraw_fd = None event_fd = None try: hidraw_fd = os.open(hidraw, os.O_RDWR) event_fd = os.open(event, os.O_RDONLY) return hidraw, event, hidraw_fd, event_fd except OSError as error: try: os.close(hidraw_fd) except Exception: pass try: os.close(event_fd) except Exception: pass log(f"Retrying touchpad device open after {error}") waiting_logged = True time.sleep(1) def main(): refresh_intensity(force=True) signal.signal(signal.SIGHUP, request_refresh) ioctl_set_req = HIDIOCSFEATURE(len(STATE["report"])) ioctl_get_req = HIDIOCGFEATURE(len(STATE["report"])) while True: hidraw_fd = None event_fd = None try: hidraw, event, hidraw_fd, event_fd = open_device_pair() log( f"Haptic touchpad ready hidraw={hidraw} input={event} level={level_for_intensity(STATE['intensity'])} intensity={STATE['intensity']} debug={int(DEBUG)} config={CONFIG_PATH}" ) apply_current_report(hidraw_fd, ioctl_set_req, ioctl_get_req, "startup") while True: if STATE["apply_requested"]: apply_current_report( hidraw_fd, ioctl_set_req, ioctl_get_req, STATE["apply_requested"], ) STATE["apply_requested"] = None ready, _, _ = select.select([event_fd], [], [], 0.5) if not ready: if refresh_intensity(): apply_current_report( hidraw_fd, ioctl_set_req, ioctl_get_req, "config-change", ) continue try: data = os.read(event_fd, EVENT_SIZE) except OSError as error: if error.errno in DEVICE_GONE_ERRNOS: raise DeviceDisconnected(error) from error raise if len(data) < EVENT_SIZE: continue _, _, ev_type, code, value = struct.unpack(EVENT_FORMAT, data) if ev_type == EV_KEY and code in (BTN_LEFT, BTN_RIGHT, BTN_MIDDLE) and value in (0, 1): if refresh_intensity(): apply_current_report( hidraw_fd, ioctl_set_req, ioctl_get_req, "config-change", ) if send_haptic_report(hidraw_fd, ioctl_set_req, STATE["report"]): log_debug( f"Applied haptic report button={BUTTON_NAMES.get(code, code)} event={'press' if value == 1 else 'release'} level={level_for_intensity(STATE['intensity'])} intensity={STATE['intensity']}" ) except DeviceDisconnected as error: log(f"Touchpad device disappeared ({error}); waiting for it to return") time.sleep(1) except KeyboardInterrupt: return finally: if event_fd is not None: os.close(event_fd) if hidraw_fd is not None: os.close(hidraw_fd) if __name__ == "__main__": main()