#!/usr/bin/env python3
"""Make a touchscreen drive the pointer, because GTK3 will not take its touch.

THE FAULT THIS EXISTS FOR, established on the first appliance and not guessed:

  * The compositor advertises touch and delivers it. A Wayland protocol capture
    on the running app shows wl_seat.capabilities(7), the client binding
    wl_touch, and a clean stream of down/motion/up on the app's OWN surface with
    correct coordinates — an injected swipe arrived as y 1344 → 1229.
  * The Flutter framework receives NONE of it. Tracing at the root of the widget
    tree logs every mouse pointer ("down kind=mouse at=258.0,38.0") and not one
    touch pointer, for taps landing on the same coordinates.
  * A GTK-level handler on the toplevel window sees nothing either, so the
    events are not merely being routed past the view: GDK's Wayland backend
    never turns them into a GTK event at all.
  * The same panel works under GNOME, and the same app takes pointer input
    perfectly on this machine.

So the break is in the GTK3 ⇄ wlroots touch path, underneath our app, and it
cannot be fixed from inside Flutter — the engine's touch plumbing is correct and
simply never gets called.

WHAT THIS DOES: reads the touchscreen directly and re-emits it as an ABSOLUTE
POINTER — the same shape as a QEMU usb-tablet, which libinput has classified as
a plain pointer for years. The app then receives ordinary pointer events, which
is the path already proven to work on this hardware.

WHY AT THIS LAYER, rather than a workaround in the app: it is device- and
toolkit-agnostic. Any touchscreen on any future appliance is matched by
capability, not by name, and nothing above it needs to know a translation
happened. It also keeps working if the app is replaced, restyled or rewritten.

WHAT IT COSTS: multi-finger gestures. A kitchen board is taps and drags, so
nothing real is lost — and a board that responds to one finger beats a board
that responds to none.
"""
import errno
import os
import selectors
import sys
import time

import libevdev

SCAN_INTERVAL = 2.0
# libinput reads absolute pointers in device units; this range is arbitrary but
# must match what we advertise, and a wide one keeps the arithmetic lossless.
OUT_MAX = 65535

ORIENTATION_FILE = "/etc/maestro-kds/orientation"


def read_orientation():
    """Which way the panel is hung, as chosen at install and stored by
    `maestro-kds-ctl rotate`.

    Re-read on every scan rather than cached at start: rotating the screen must
    not need a service restart, or the first thing anyone does after mounting a
    unit sideways is reboot it.
    """
    try:
        with open(ORIENTATION_FILE) as fh:
            v = fh.read().strip()
        if v in ("normal", "90", "180", "270"):
            return v
    except OSError:
        pass
    return "normal"


def orient(px, py, transform):
    """Rotate panel-normalised (0..1) coordinates into screen space.

    THIS HAS TO BE DONE HERE. The compositor rotates what is DRAWN and, for a
    device it classifies as an absolute pointer, passes the coordinates through
    untransformed — measured on the appliance with the panel at transform 90:
    a touch at the screen's top-left arrived at (1068, 79) of a 1080x1920
    surface, top-right at (1080, 1920), bottom-left at (35, 87). Left-to-right
    became top-to-bottom and top-to-bottom became right-to-left: a clean 90°
    rotation, no flip.
    """
    if transform == "90":
        return py, 1.0 - px
    if transform == "180":
        return 1.0 - px, 1.0 - py
    if transform == "270":
        return 1.0 - py, px
    return px, py


def log(msg):
    sys.stderr.write(f"maestro-touch-pointer: {msg}\n")
    sys.stderr.flush()


def is_touchscreen(dev):
    """Match by CAPABILITY, never by device name.

    The first appliance's panel calls itself "ILITEK Multi-Touch-V3000"; the
    next one will call itself something else. Anything with multitouch position
    axes and a direct-touch property is a touchscreen, whoever made it.
    """
    if not dev.has(libevdev.EV_ABS.ABS_MT_POSITION_X):
        return False
    if not dev.has(libevdev.EV_ABS.ABS_MT_POSITION_Y):
        return False
    return dev.has(libevdev.INPUT_PROP_DIRECT)


def make_pointer():
    """An absolute pointer, deliberately NOT a touchscreen.

    No ABS_MT_* axes and no INPUT_PROP_DIRECT: those two are exactly what make
    libinput classify a device as a touchscreen and route it back down the
    broken path. With BTN_LEFT and plain ABS_X/ABS_Y it is the same shape as a
    virtual tablet, which every compositor treats as a pointer.
    """
    d = libevdev.Device()
    d.name = "Maestro Touch Pointer"
    d.id = {"bustype": 0x03, "vendor": 0x1D6B, "product": 0x0104, "version": 1}
    info = libevdev.InputAbsInfo(minimum=0, maximum=OUT_MAX, resolution=0)
    d.enable(libevdev.EV_ABS.ABS_X, info)
    d.enable(libevdev.EV_ABS.ABS_Y, info)
    d.enable(libevdev.EV_KEY.BTN_LEFT)
    return d.create_uinput_device()


class Source:
    """One physical touchscreen, grabbed so nothing else sees its events."""

    def __init__(self, path):
        self.path = path
        # NON-BLOCKING, and this matters more than it looks. Opened blocking,
        # the read loop parks forever inside the FIRST device's events() call:
        # it never rescans, so a touchscreen plugged in later is never picked
        # up, and on a machine with two input devices only one of them works.
        # Caught on the appliance — the panel worked while a second device was
        # invisible, which reads exactly like "that one is broken".
        self.fd = os.fdopen(os.open(path, os.O_RDONLY | os.O_NONBLOCK), "rb",
                            buffering=0)
        self.dev = libevdev.Device(self.fd)
        self.name = self.dev.name
        ax = self.dev.absinfo[libevdev.EV_ABS.ABS_MT_POSITION_X]
        ay = self.dev.absinfo[libevdev.EV_ABS.ABS_MT_POSITION_Y]
        self.minx, self.maxx = ax.minimum, ax.maximum
        self.miny, self.maxy = ay.minimum, ay.maximum
        # EVIOCGRAB. Without it the compositor ALSO reads the panel and keeps
        # feeding the broken touch path, so every tap would arrive twice: once
        # as a pointer that works and once as a touch that is discarded — and
        # any future fix upstream would silently double every tap.
        self.dev.grab()
        # Slot state. Only slot 0 is forwarded: a pointer has one position, and
        # a second finger must not yank the cursor across the screen mid-drag.
        self.slot = 0
        self.x = None
        self.y = None
        self.down = False

    def norm(self, v, lo, hi):
        """Panel units → 0.0-1.0, so the rotation below is plain arithmetic
        instead of four sets of axis-specific special cases."""
        if hi <= lo:
            return 0.0
        v = max(lo, min(hi, v))
        return (v - lo) / (hi - lo)

    def close(self):
        try:
            self.dev.ungrab()
        except Exception:
            pass
        try:
            self.fd.close()
        except Exception:
            pass


def main():
    if os.geteuid() != 0:
        log("must run as root (needs /dev/input and /dev/uinput)")
        return 1

    pointer = make_pointer()
    log("virtual absolute pointer created")

    sources = {}
    last_scan = 0.0
    transform = read_orientation()
    log(f"orientation: {transform}")

    while True:
        now = time.monotonic()
        if now - last_scan >= SCAN_INTERVAL:
            last_scan = now
            fresh = read_orientation()
            if fresh != transform:
                transform = fresh
                log(f"orientation changed: {transform}")
            # Re-scan rather than watch udev: a touchscreen on a kitchen wall is
            # a USB device somebody unplugs while cleaning, and a daemon that
            # only enumerated at boot would need a reboot to notice it return.
            for entry in sorted(os.listdir("/dev/input")):
                if not entry.startswith("event"):
                    continue
                path = f"/dev/input/{entry}"
                if path in sources:
                    continue
                try:
                    src = Source(path)
                except Exception:
                    continue
                if not is_touchscreen(src.dev):
                    src.close()
                    continue
                sources[path] = src
                log(f"grabbed touchscreen: {src.name} ({path})")
            for path in list(sources):
                if not os.path.exists(path):
                    sources.pop(path).close()
                    log(f"touchscreen removed: {path}")

        if not sources:
            time.sleep(0.4)
            continue

        idle = True
        for path, src in list(sources.items()):
            try:
                for e in src.dev.events():
                    idle = False
                    if e.matches(libevdev.EV_ABS.ABS_MT_SLOT):
                        src.slot = e.value
                    elif e.matches(libevdev.EV_ABS.ABS_MT_TRACKING_ID):
                        if src.slot == 0:
                            if e.value == -1:
                                if src.down:
                                    pointer.send_events([
                                        libevdev.InputEvent(
                                            libevdev.EV_KEY.BTN_LEFT, 0),
                                        libevdev.InputEvent(
                                            libevdev.EV_SYN.SYN_REPORT, 0),
                                    ])
                                    src.down = False
                            else:
                                src.pending_press = True
                                # HOLD THE PRESS UNTIL THIS TOUCH REPORTS ITS OWN
                                # POSITION. Some panels send a new contact's
                                # tracking id in one frame and its X/Y in the
                                # NEXT. Pressing on the id-only frame clicks at the
                                # PREVIOUS finger's coordinates and then drags the
                                # held button to the real spot as the position
                                # lands — which on the board reads as "the first
                                # tap only moves the cursor, the second tap
                                # clicks" and a cursor that "drags" to the button.
                                # Clearing the coords makes the SYN handler below
                                # wait for a fresh X/Y (a new contact always
                                # reports one) before it moves-and-presses, so the
                                # click always lands where the finger actually is.
                                src.x = None
                                src.y = None
                    elif e.matches(libevdev.EV_ABS.ABS_MT_POSITION_X):
                        if src.slot == 0:
                            src.x = src.norm(e.value, src.minx, src.maxx)
                    elif e.matches(libevdev.EV_ABS.ABS_MT_POSITION_Y):
                        if src.slot == 0:
                            src.y = src.norm(e.value, src.miny, src.maxy)
                    elif e.matches(libevdev.EV_SYN.SYN_REPORT):
                        if src.x is None or src.y is None:
                            continue
                        ox, oy = orient(src.x, src.y, transform)
                        out = [
                            libevdev.InputEvent(libevdev.EV_ABS.ABS_X,
                                                int(ox * OUT_MAX)),
                            libevdev.InputEvent(libevdev.EV_ABS.ABS_Y,
                                                int(oy * OUT_MAX)),
                        ]
                        # MOVE BEFORE PRESSING. A press reported at the previous
                        # position lands wherever the last finger was — on a
                        # board of adjacent ticket buttons that is a wrong item
                        # marked ready, which is worse than no touch at all.
                        if getattr(src, "pending_press", False) and not src.down:
                            out.append(libevdev.InputEvent(
                                libevdev.EV_SYN.SYN_REPORT, 0))
                            out.append(libevdev.InputEvent(
                                libevdev.EV_KEY.BTN_LEFT, 1))
                            src.down = True
                            src.pending_press = False
                        out.append(libevdev.InputEvent(
                            libevdev.EV_SYN.SYN_REPORT, 0))
                        pointer.send_events(out)
            except OSError as err:
                if err.errno in (errno.ENODEV, errno.EIO):
                    sources.pop(path).close()
                    log(f"touchscreen disappeared: {path}")
                # EAGAIN simply means no events are pending right now.
            except Exception as err:  # never let one bad device stop the rest
                log(f"error on {path}: {err}")

        if idle:
            time.sleep(0.004)

    return 0


if __name__ == "__main__":
    sys.exit(main())
