Skip to content

t1d-probe.py

Files / gamesir-t1d / t1d-probe.py

Files in gamesir-t1d/

DownloadRawPython · 101 lines · 3.8 KB

From Dictation on Linux with Handy, and tidy text with OpenCode Go and Use the Tello's GameSir T1d controller as a Linux gamepad. MIT licence: LICENSE.

gamesir-t1d/t1d-probe.py
#!/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)))

Text, photos and drawings: CC BY 4.0. Code: MIT. Share and adapt them, crediting HomeLab Guides (blog.jacq.cc). Licence and privacy