#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 jacq.cc
"""Probe a GameSir T1d over BLE: list its GATT services, read the Device
Information service, then print decoded input for a while.

Setup (Kubuntu 26.04, once):
    python3 -m venv ~/.venvs/t1d && ~/.venvs/t1d/bin/pip install bleak
Run (controller on, LEDs blinking = advertising):
    ~/.venvs/t1d/bin/python gamesir-ctrl/t1d-probe.py [seconds]

No pairing is needed: the T1d accepts a plain BLE connection.
"""
import asyncio
import sys
import time

from bleak import BleakClient, BleakScanner

NAME_PREFIX = "Gamesir-T1d"
INPUT_CHAR = "00008651-0000-1000-8000-00805f9b34fb"   # 20-byte input reports (notify)
DIS = {  # Device Information service
    "2a24": "model", "2a25": "serial", "2a26": "firmware", "2a27": "hardware",
    "2a50": "pnp_id", "2a00": "name", "2a04": "conn_params",
}
AXES = ("lx", "ly", "rx", "ry", "l2", "r2")


def decode(d: bytes) -> dict:
    """Byte layout from dg-hub/gamesir-t1d-driver and the ElishaAz gist.
    Sticks are 10-bit (0..1023), triggers 8-bit (0..255)."""
    return {
        "type": d[0:2].hex(),
        "lx": (d[2] << 2) | (d[3] >> 6),
        "ly": ((d[3] & 0x3F) << 4) | (d[4] >> 4),
        "rx": ((d[4] & 0x0F) << 6) | (d[5] >> 2),
        "ry": ((d[5] & 0x03) << 8) | d[6],
        "l2": d[7],
        "r2": d[8],
        "keys": f"{d[9]:08b}",   # A/B/X/Y/L1/R1 bits
        "k2": f"{d[10]:08b}",    # C1/C2, L2/R2 digital, L3/R3 bits
        "dpad": d[11],           # 1 up, 3 right, 5 down, 7 left (diagonals in between)
    }


def changed(a: dict, b: dict) -> bool:
    if a["type"] != b["type"] or a["keys"] != b["keys"] or a["k2"] != b["k2"] or a["dpad"] != b["dpad"]:
        return True
    return sum(abs(a[k] - b[k]) for k in AXES) > 12   # ignore pot jitter


async def main(seconds: float) -> int:
    print("scanning for", NAME_PREFIX, "...")
    dev = await BleakScanner.find_device_by_filter(
        lambda d, _ad: (d.name or "").startswith(NAME_PREFIX), timeout=10)
    if dev is None:
        print("not found: is the controller on and blinking?")
        return 1
    print("found", dev.name, dev.address)

    async with BleakClient(dev, timeout=20) as client:
        for svc in client.services:
            print("service", svc.uuid)
            for ch in svc.characteristics:
                print("   char", ch.uuid, ",".join(ch.properties))
        for short, label in DIS.items():
            try:
                raw = await client.read_gatt_char(f"0000{short}-0000-1000-8000-00805f9b34fb")
            except Exception as exc:  # noqa: BLE001
                print(f"{label:12s} error: {exc}")
                continue
            text = raw.decode(errors="replace").rstrip("\x00") if short not in ("2a50", "2a04") else ""
            print(f"{label:12s} {raw.hex():34s} {text}")

        count = 0
        prev = None
        stamps: list[float] = []
        t0 = time.monotonic()

        def on_notify(_handle, data: bytearray) -> None:
            nonlocal count, prev
            count += 1
            stamps.append(time.monotonic())
            cur = decode(bytes(data))
            if prev is None or changed(prev, cur):
                print(f"{time.monotonic() - t0:6.2f}s  {bytes(data).hex()}  {cur}")
            prev = cur

        await client.start_notify(INPUT_CHAR, on_notify)
        print(f"listening {seconds:.0f} s: move the sticks, press buttons")
        await asyncio.sleep(seconds)
        await client.stop_notify(INPUT_CHAR)

    gaps = sorted(b - a for a, b in zip(stamps, stamps[1:]))
    if gaps:
        print(f"{count} notifications; shortest gap {gaps[0] * 1000:.0f} ms, median {gaps[len(gaps) // 2] * 1000:.0f} ms")
    return 0


if __name__ == "__main__":
    sys.exit(asyncio.run(main(float(sys.argv[1]) if len(sys.argv) > 1 else 15.0)))
