From 7958831f90b350129662f9064f176bc023337bd0 Mon Sep 17 00:00:00 2001 From: mar77i Date: Fri, 13 Dec 2024 00:30:28 +0100 Subject: [PATCH] initial commit --- .gitignore | 3 + bluetooth.py | 42 +++++++ vectors.py | 309 ++++++++++++++++++++++++++++++++++++++++++++++++ xinput.py | 74 ++++++++++++ xrandr.py | 190 +++++++++++++++++++++++++++++ zenbook_conf.py | 110 +++++++++++++++++ 6 files changed, 728 insertions(+) create mode 100644 .gitignore create mode 100644 bluetooth.py create mode 100755 vectors.py create mode 100644 xinput.py create mode 100644 xrandr.py create mode 100755 zenbook_conf.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07da734 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea/ +__pycache__/ +venv/ diff --git a/bluetooth.py b/bluetooth.py new file mode 100644 index 0000000..b9b2185 --- /dev/null +++ b/bluetooth.py @@ -0,0 +1,42 @@ +from time import sleep +from traceback import print_exception + +import gi +import gi.repository.GLib +import pydbus + + +class BluetoothConf: + ADAPTER = "org.bluez.Adapter1" + RETRIES = 20 + RETRY_SLEEP = 5 + + def __init__(self): + self.bus = pydbus.SystemBus().get("org.bluez", "/org/bluez/hci0") + self.conf = self.bus.Get(self.ADAPTER, "Powered") + + def update(self, value): + assert isinstance(value, bool) + if value == self.conf: + return + for retry in range(self.RETRIES + 1): + try: + self.bus.Set(self.ADAPTER, "Powered", pydbus.Variant("b", value)) + except gi.repository.GLib.GError as e: + if retry >= self.RETRIES: + raise + print_exception(e) + print( + f"Sleeping {self.RETRY_SLEEP} seconds to try again " + f"({retry + 1} / {self.RETRIES})..." + ) + sleep(self.RETRY_SLEEP) + else: + break + # dbus-send --system + # --dest=org.bluez --print-reply /org/bluez/hci0 + # org.freedesktop.DBus.Properties.Set + # string:org.bluez.Adapter1 + # string:Powered + # variant:boolean:false + self.conf = value diff --git a/vectors.py b/vectors.py new file mode 100755 index 0000000..20880fd --- /dev/null +++ b/vectors.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 + +from collections.abc import Sequence +from itertools import chain +from math import atan2, cos, pi, sin + +import pygame + +tau = 2 * pi + + +class Shape: + def fit(self, pos, unit): + raise NotImplementedError + + +class Rect(Shape): + def __init__(self, *args): + if len(args) == 2: + args = tuple(chain.from_iterable(args)) + self.left, self.top, self.width, self.height = args + + def fit(self, pos, unit): + return pygame.Rect( + (round(pos[0] + self.left * unit[0]), round(pos[1] + self.top * unit[1])), + (round(self.width * unit[0]), round(self.height * unit[1])), + ) + + def draw(self, surf, pos, unit, color): + pygame.draw.rect(surf, color, self.fit(pos, unit)) + + +class Polygon(Shape): + def __init__(self, points): + self.points = list(points) + + def fit(self, pos, unit): + return [ + (round(pos[0] + point[0] * unit[0]), round(pos[1] + point[1] * unit[1])) + for point in self.points + ] + + def draw(self, surf, pos, unit, color): + pygame.draw.polygon(surf, color, self.fit(pos, unit)) + + +class Circle(Shape): + def __init__(self, pos, radius): + self.pos = pos + self.radius = radius + + def fit(self, pos, unit): + return pygame.Rect( + ( + round(pos[0] + (self.pos[0] - self.radius) * unit[0]), + round(pos[1] + (self.pos[1] - self.radius) * unit[1]), + ), + (round(self.radius * 2 * unit[0]), round(self.radius * 2 * unit[1])), + ) + + def draw(self, surf, pos, unit, color): + pygame.draw.ellipse(surf, color, self.fit(pos, unit)) + + +class StrokeLine(Polygon): + num_vertices = 32 + + def __init__(self, start, end, width): + super().__init__( + list(self.make_line_shape(start, end, self.num_vertices, width / 2)) + ) + + @staticmethod + def make_line_shape(p1, p2, n, shape_r): + angle = atan2(p1[1] - p2[1], p1[0] - p2[0]) + if angle < 0: + angle += tau + # 0 <= angle < tau + # corner below 270° + initial_corner = int((angle + pi * 3 / 2) * n / tau + .5) % n + # corner below 90° + opposing_corner = int((angle + pi / 2) * n / tau + .5) % n + shape = [ + (cos(i * tau / n) * shape_r, sin(i * tau / n) * shape_r) + for i in range(n) + ] + p = p1 + i = initial_corner + while i <= n + initial_corner: + mod_i = i % n + s = shape[mod_i] + yield (p[0] + s[0], p[1] + s[1]) + if mod_i == opposing_corner and p == p1 and p1 != p2: + p = p2 + else: + i += 1 + + +class Shapes(Shape): + def __init__(self, shapes): + self.shapes = shapes + + def draw(self, surf, pos, unit, color): + for shape in self.shapes: + shape.draw(surf, pos, unit, color) + + +class StrokeCircle(Shapes): + def __init__(self, center, radius, width): + super().__init__([ + StrokeLine( + ( + center[0] + cos(a) * radius, + center[1] + sin(a) * radius, + ), + ( + center[0] + cos(b) * radius, + center[1] + sin(b) * radius, + ), + width, + ) + for a, b in self.get_angle_segments() + ]) + + @staticmethod + def get_angle_segments(): + num_vertices = StrokeLine.num_vertices + a = 0 + for i in range(1, num_vertices + 1): + b = tau * i / num_vertices + yield a, b + a = b + + +class StrokeCircleSegment(Shapes): + def __init__(self, center, radius, start_angle, end_angle, width): + super().__init__([ + StrokeLine( + ( + center[0] + cos(a) * radius, + center[1] + sin(a) * radius, + ), + ( + center[0] + cos(b) * radius, + center[1] + sin(b) * radius, + ), + width, + ) + for a, b in self.get_angle_segments(start_angle, end_angle) + ]) + + @classmethod + def get_angle_segments(cls, start_angle, end_angle): + num_vertices = StrokeLine.num_vertices + start_angle, end_angle = ( + min(start_angle, end_angle), max(start_angle, end_angle) + ) + diff_angle = end_angle - start_angle + a = 0 + for i in range(1, num_vertices + 1): + b = tau * i / num_vertices + if b >= diff_angle: + yield a + start_angle, end_angle + break + yield a + start_angle, b + start_angle + a = b + + +laptop_single = Shapes( + [ + Rect((2, 3), (16.4, 1)), + Rect((2, 4), (1, 8.1)), + Rect((17.4, 4), (1, 8.1)), + Rect((2, 12.1), (16.4, 1)), + Polygon([(2, 13.1), (18.4, 13.1), (22.4, 18.1), (6, 18.1)]), + ] +) +laptop_double = Shapes( + [ + Rect((2, 3), (16.4, 1)), + Rect((2, 4), (1, 8.1)), + Rect((17.4, 4), (1, 8.1)), + Rect((2, 12.1), (16.4, 1)), + Polygon([(2, 13.1), (3, 13.1), (6.2, 17.1), (5.2, 17.1)]), + Polygon([(17.4, 13.1), (18.4, 13.1), (21.6, 17.1), (20.4, 17.1)]), + Polygon([(5.2, 17.1), (21.6, 17.1), (22.4, 18.1), (6, 18.1)]), + ] +) +laptop_vertical = Shapes( + [ + Rect((0.5, 1), (17.75, 1)), + Rect((0.5, 2), (1, 14.4)), + Rect((9, 2), (1, 14.4)), + Rect((17.25, 2), (1, 14.4)), + Rect((0.5, 16.4), (17.75, 1)), + Polygon([(2, 17.4), (6, 22.4), (20.75, 22.4), (16.75, 17.4)]), + ] +) +FINGER_RADIUS = 1.25 +touchscreen = Shapes( + [ + StrokeCircleSegment((12, 16), 5, 0, tau * 3 / 8, 1), + StrokeLine( + (12 + cos(tau * 3 / 8) * 5, 16 + sin(tau * 3 / 8) * 5), + ( + 4 + cos(tau * 3 / 8) * FINGER_RADIUS, + 13 + sin(tau * 3 / 8) * FINGER_RADIUS, + ), + 1, + ), + StrokeCircleSegment((4, 13), FINGER_RADIUS, tau * 3 / 8, tau * 7 / 8, 1), + StrokeLine( + ( + 4 + cos(tau * 7 / 8) * FINGER_RADIUS, + 13 + sin(tau * 7 / 8) * FINGER_RADIUS, + ), + (7, 13.5), + 1, + ), + StrokeLine((7, 13.5), (7, 6), 1), + StrokeCircleSegment((8.25, 6), FINGER_RADIUS, pi, tau, 1), + StrokeLine((9.5, 6), (9.5, 11), 1), + StrokeCircleSegment((11, 11.5), FINGER_RADIUS, tau * 5 / 8, tau * 7 / 8, 1), + StrokeCircleSegment((13.25, 12), FINGER_RADIUS, tau * 5 / 8, tau * 7 / 8, 1), + StrokeCircleSegment((15.5, 12.5), FINGER_RADIUS, tau * 5 / 8, tau, 1), + StrokeLine((16.75, 12.5), (17, 16), 1), + StrokeCircleSegment((8.25, 6), 3, pi, tau, 1), + StrokeCircleSegment((8.25, 6), 5, pi, tau, 1), + ] +) +stylus = Shapes( + [ + StrokeCircleSegment((3, 3), 1.5, tau * 3 / 8, tau * 7 / 8, 1), + StrokeLine( + (3 + cos(tau * 3 / 8) * 1.5, 3 + sin(tau * 3 / 8) * 1.5), + (16 + cos(tau * 3 / 8) * 1.5, 16 + sin(tau * 3 / 8) * 1.5), + 1, + ), + StrokeLine( + (3 + cos(tau * 7 / 8) * 1.5, 3 + sin(tau * 7 / 8) * 1.5), + (16 + cos(tau * 7 / 8) * 1.5, 16 + sin(tau * 7 / 8) * 1.5), + 1, + ), + StrokeLine( + (16 + cos(tau * 3 / 8) * 1.5, 16 + sin(tau * 3 / 8) * 1.5), + (18, 18), + 1, + ), + StrokeLine( + (16 + cos(tau * 7 / 8) * 1.5, 16 + sin(tau * 7 / 8) * 1.5), + (18, 18), + 1, + ), + StrokeLine((11, 11), (12, 12), 1), + ] +) +shapes = [ + { + "shape": laptop_single, + "pos": (100, 100), + }, + { + "shape": laptop_double, + "pos": (600, 100), + }, + { + "shape": laptop_vertical, + "pos": (1100, 100), + }, + { + "shape": touchscreen, + "pos": (100, 600), + }, + { + "shape": stylus, + "pos": (600, 600), + }, +] + + +def main(): + surf = pygame.display.set_mode((2000, 1600)) + running = True + dirty = False + clock = pygame.time.Clock() + while True: + for ev in pygame.event.get(): + if ev.type == pygame.QUIT: + running = False + break + elif ev.type == pygame.WINDOWEXPOSED: + dirty = True + elif ev.type == pygame.KEYDOWN: + if ev.key == pygame.K_ESCAPE: + running = False + break + if not running: + break + elif dirty: + surf.fill("black") + for item in shapes: + item["shape"].draw(surf, item["pos"], (16, 16), "green") + pygame.display.update() + dirty = False + clock.tick(60) + + +if __name__ == "__main__": + main() diff --git a/xinput.py b/xinput.py new file mode 100644 index 0000000..1176f55 --- /dev/null +++ b/xinput.py @@ -0,0 +1,74 @@ +import os +import re +import subprocess + + +class XinputConf: + DEVICES = { + "ELAN9008:00 04F3:425B": { + "output": "eDP-1", + "type": "touchpad", + }, + "ELAN9008:00 04F3:425B Touchpad": { + "output": "eDP-1", + "type": "touchpad", + }, + "ELAN9008:00 04F3:425B Stylus Pen (0)": { + "output": "eDP-1", + "type": "stylus", + }, + "ELAN9009:00 04F3:425A": { + "output": "eDP-2", + "type": "touchpad", + }, + "ELAN9009:00 04F3:425A Touchpad": { + "output": "eDP-2", + "type": "touchpad", + }, + "ELAN9009:00 04F3:425A Stylus Pen (0)": { + "output": "eDP-2", + "type": "stylus", + }, + } + DEVICE_PATTERN = re.compile( + "^([\u239c ]\\s*\u21b3|\u223c)\\s+(([^ ]| [^ ])+)\\s*\tid=(\\d+)\t" + ) + + def __init__(self, xrandr_conf): + self.xrandr_conf = xrandr_conf + self.current_conf = self.get_conf() + + def get_conf(self): + output = subprocess.check_output(["xinput", "list"], text=True) + devices = [] + for line in output.split(os.linesep): + match = self.DEVICE_PATTERN.match(line) + if match: + if match.group(2) not in self.DEVICES: + continue + devices.append( + { + "name": match.group(2), + "enabled": not match.group(1).startswith("\u223c"), + "id": match.group(4), + **self.DEVICES[match.group(2)], + } + ) + elif line and "core pointer" not in line and "core keyboard" not in line: + print("line", repr(line)) + return devices + + def update_device(self, device, enable=True): + if enable: + subprocess.run(["xinput", "enable", device["id"]]) + subprocess.run(["xinput", "map-to-output", device["id"], device["output"]]) + else: + subprocess.run(["xinput", "disable", device["id"]]) + + def update(self, mode): + enable_second = mode != "single" + for device in self.current_conf: + if device["output"] == self.xrandr_conf.OUTPUTS[0]: + self.update_device(device) + elif device["output"] == self.xrandr_conf.OUTPUTS[1]: + self.update_device(device, enable_second) diff --git a/xrandr.py b/xrandr.py new file mode 100644 index 0000000..c203b79 --- /dev/null +++ b/xrandr.py @@ -0,0 +1,190 @@ +import os +import re +import subprocess + + +class XrandrConf: + CONNECTED_MAPPING = { + "connected": True, + "disconnected": False, + } + RESOLUTION_PATTERN = re.compile(r"(\d+)x(\d+)\+(\d+)\+(\d+)") + MM_SIZE_PATTERN = re.compile(r"(\d+)mm x (\d+)mm") + DIRECTIONS = ("normal", "left", "inverted", "right", "invalid direction") + REFLECTIONS = ( + "none", "[Xx] axis", "[Yy] axis", "X and Y axis", "invalid reflection" + ) + DIRECTIONS_REFLECTIONS_PATTERN = re.compile( + f"({'|'.join(DIRECTIONS)})( {'|'.join(REFLECTIONS)})? \\(" + ) + AVAILABLE_DIRECTIONS_REFLECTIONS_PATTERN = re.compile( + f"{'|'.join(DIRECTIONS)}|{'|'.join(REFLECTIONS)}" + ) + MODE_PATTERN = re.compile(r"^\s{2}([^ ]+) \(0x([0-9A-Fa-f]+)\)\s+([^ ]+)MHz") + MODE_FLAGS_PATTERN = re.compile("[-+][CHV]Sync|Interlace|DoubleScan") + WIDTH_HEIGHT_PATTERN = re.compile(r"^\s{8}(h: width|v: height)\s+(\d+) ") + OUTPUTS = ("eDP-1", "eDP-2") + + def __init__(self): + self.current_conf = self.get_conf() + + @staticmethod + def strip_each(ss): + return (s.strip() for s in ss) + + @staticmethod + def int_tuple(it): + return tuple(int(i) for i in it) + + @classmethod + def parse_screen_line(cls, line): + screen_id, sizes = cls.strip_each(line.split(":", 1)) + return { + "screen": int(screen_id[7:]), + "sizes": [ + { + "name": name, + "size": cls.int_tuple(cls.strip_each(size.split("x"))), + } for name, size in ( + cls.strip_each(p.split(" ", 1)) + for p in cls.strip_each(sizes.split(",")) + ) + ], + "outputs": [], + } + + @classmethod + def parse_output_line(cls, line): + name, connected, rest = cls.strip_each(line.split(" ", 2)) + output = { + "name": name, + "connected": cls.CONNECTED_MAPPING.get( + connected, "unknown connection" + ), + } + if output["connected"] is False: + return output + resolution = cls.RESOLUTION_PATTERN.search(rest) + output["active"] = bool(resolution) + if resolution is None: + return output + parsed = cls.int_tuple(resolution.group(i) for i in range(1, 5)) + direction_reflection = ( + cls.DIRECTIONS_REFLECTIONS_PATTERN.search(rest) + ) + end = direction_reflection.end() + output.update( + { + "size": parsed[:2], + "pos": parsed[2:], + "primary": rest.startswith("primary "), + "direction": direction_reflection.group(1), + "reflection": direction_reflection.group(2) or "none", + "available_rotations_reflections": [ + m.group(0) + for m in cls.AVAILABLE_DIRECTIONS_REFLECTIONS_PATTERN.finditer( + rest[end:rest.find(")", end)] + ) + ], + "modes": [] + } + ) + mm_size = cls.MM_SIZE_PATTERN.search(rest) + if mm_size: + output["mm_size"] = ( + int(mm_size.group(1)), int(mm_size.group(2)) + ) + return output + + @classmethod + def parse_mode(cls, line, width_line, height_line): + if line.endswith(" +preferred"): + line = line[:-11] + preferred = True + else: + preferred = False + if line.endswith(" *current"): + line = line[:-9] + current = True + else: + current = False + match = cls.MODE_PATTERN.match(line) + return { + "name": match.group(1), + "width": int(cls.WIDTH_HEIGHT_PATTERN.match(width_line).group(2)), + "height": int(cls.WIDTH_HEIGHT_PATTERN.match(height_line).group(2)), + "mode_id": int(match.group(2), 16), + "refresh_rate": float(match.group(3)), + "preferred": preferred, + "current": current, + "mode_flags": cls.MODE_FLAGS_PATTERN.findall(line), + } + + @classmethod + def get_conf(cls): + screens = [] + current_screen = None + output = subprocess.check_output(["xrandr", "--verbose"], text=True) + lines = output.split(os.linesep) + for i, line in enumerate(lines): + if not line: + continue + elif line.startswith("Screen "): + current_screen = len(screens) + screens.append(cls.parse_screen_line(line)) + elif not line.startswith("\t") and not line.startswith(" "): + screens[current_screen]["outputs"].append(cls.parse_output_line(line)) + elif line.startswith(" "): + output = screens[current_screen]["outputs"][-1] + if line[3] == " " or "modes" not in output: + continue + output["modes"].append(cls.parse_mode(*lines[i:i + 3])) + #print(json.dumps(screens, indent=4)) + return screens + + @classmethod + def call(cls, output, *args, **kwargs): + args = [f"--{arg}" for arg in args] + for key, value in kwargs.items(): + args.extend((f"--{key.replace("_", "-")}", value)) + return subprocess.run(["xrandr", "--output", cls.OUTPUTS[output], *args]) + + def update(self, mode): + if mode not in ("single", "double", "vertical"): + raise ValueError(f"unknown mode: {mode}") + self.call(1, "off", "noprimary") + if mode in ("single", "double"): + self.call(0, "auto", "primary", rotate="normal") + if mode == "double": + self.call(1, "auto", rotate="normal", below=self.OUTPUTS[0]) + if mode == "vertical": + self.call(0, "auto", rotate="left") + self.call(1, "auto", rotate="left", left_of=self.OUTPUTS[0]) + self.current_conf = self.get_conf() + + def get_relevant_outputs(self): + screen = next(s for s in self.current_conf if s["screen"] == 0) + outputs = [None, None] + to_finds = list(self.OUTPUTS) + for output in screen["outputs"]: + for i, to_find in enumerate(to_finds): + if output["name"] == to_find: + outputs[self.OUTPUTS.index(to_find)] = output + to_finds.remove(to_find) + if not to_finds: + return outputs + raise ValueError(f"Outputs not found: {', '.join(self.OUTPUTS)}") + + def is_active(self, mode): + outputs = self.get_relevant_outputs() + if mode == "vertical": + expected_direction = "left" + else: + expected_direction = "normal" + second_active = mode != "single" + return ( + outputs[0]["active"] + and outputs[0]["direction"] == expected_direction + and outputs[1]["active"] == second_active + and (not second_active or outputs[1]["direction"] == expected_direction) + ) diff --git a/zenbook_conf.py b/zenbook_conf.py new file mode 100755 index 0000000..f270af9 --- /dev/null +++ b/zenbook_conf.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python + +import pygame + +from bluetooth import BluetoothConf +from vectors import laptop_double, laptop_single, laptop_vertical +from xinput import XinputConf +from xrandr import XrandrConf + +# - slider button with "undecided" state +# - slider button for bluetooth +# - that springs on for the 2-screen modes, because the keyboard needs it +# - slider button for touchscreen (undecided if disabled on second screen) +# - extra button to calibrate +# - slider button for stylus (undecided if disabled on second screen) +# - extra button to calibrate +# - recognize current setting for touchscreen and stylus +# - ability to display messages +# - below the three screen buttons +# - next to the slider button for touchscreen +# - next to the slider button for stylus + + +class ZenbookConf: + SHAPES = ( + { + "name": "single", + "shape": laptop_single, + "rect": pygame.Rect((68, 68), (416, 416)), + }, + { + "name": "double", + "shape": laptop_double, + "rect": pygame.Rect((568, 68), (416, 416)), + }, + { + "name": "vertical", + "shape": laptop_vertical, + "rect": pygame.Rect((1068, 68), (416, 416)), + }, + ) + + def __init__(self): + pygame.init() + self.surf = pygame.display.set_mode((1536, 768)) + self.font = pygame.font.Font(None, size=64) + self.clock = pygame.time.Clock() + self.running = True + self.dirty = False + self.xrandr_conf = XrandrConf() + self.xinput_conf = XinputConf(self.xrandr_conf) + self.bluetooth_conf = BluetoothConf() + self.current_fps = -1 + + def handle_event(self, ev): + if ev.type == pygame.QUIT: + self.running = False + return False + elif ev.type == pygame.WINDOWEXPOSED: + self.dirty = True + elif ev.type == pygame.KEYDOWN: + if ev.key == pygame.K_ESCAPE: + self.running = False + return False + elif ev.type == pygame.MOUSEBUTTONDOWN: + for item in self.SHAPES: + if item["rect"].collidepoint(ev.pos): + self.xrandr_conf.update(item["name"]) + self.xinput_conf.update(item["name"]) + if item["name"] != "single": + self.bluetooth_conf.update(True) + self.dirty = True + return True + + def update(self): + if self.clock.get_fps() != self.current_fps: + self.current_fps = int(self.clock.get_fps()) + self.dirty = True + + def draw(self): + self.surf.fill(0x333333) + for item in self.SHAPES: + if self.xrandr_conf.is_active(item["name"]): + pygame.draw.rect(self.surf, "lime", item["rect"], 8) + else: + pygame.draw.rect(self.surf, "gray", item["rect"], 8) + item["shape"].draw( + self.surf, + (item["rect"].left + 32, item["rect"].top + 32), + (16, 16), + "white", + ) + + def run(self): + while True: + for ev in pygame.event.get(): + if not self.handle_event(ev): + break + if not self.running: + break + self.update() + if self.dirty: + self.draw() + pygame.display.update() + self.dirty = False + self.clock.tick(60) + + +if __name__ == "__main__": + ZenbookConf().run() -- 2.47.1