Files
omarchy-pkgs/pkgbuilds/dell-xps-touchpad-haptics/dell-xps-touchpad-haptics-daemon
T
2026-05-08 01:04:51 -04:00

382 lines
11 KiB
Python
Executable File

#!/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()