VulpesOS/tundra/tools/usb_protocol.py
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

103 lines
4.8 KiB
Python

# SPDX-License-Identifier: MPL-2.0
"""Bounded Linux usbfs transport for the read-only Tundra diagnostic protocol."""
import ctypes
import fcntl
from pathlib import Path
import struct
import time
class Bulk(ctypes.Structure):
_fields_=[('ep',ctypes.c_uint),('length',ctypes.c_uint),('timeout',ctypes.c_uint),('data',ctypes.c_void_p)]
def parse_endpoints(data, configuration):
"""Read the active vendor interface; FunctionFS may renumber endpoints."""
candidates = []
current = None
active = False
offset = 0
while offset < len(data):
if len(data)-offset < 2:
raise ValueError('Truncated USB descriptor')
size, kind = data[offset:offset+2]
if size < 2 or offset+size > len(data):
raise ValueError('Invalid USB descriptor length')
item = data[offset:offset+size]
if kind == 2:
if size < 9:
raise ValueError('Invalid configuration descriptor')
active = item[5] == configuration
current = None
elif kind == 4:
if size < 9:
raise ValueError('Invalid interface descriptor')
current = None
if active and item[3] == 0 and item[5:8] == bytes([255,0,0]):
current = {'interface':item[2], 'count':item[4], 'endpoints':[]}
candidates.append(current)
elif kind == 5 and current is not None:
if size < 7:
raise ValueError('Invalid endpoint descriptor')
current['endpoints'].append((item[2], item[3]&3))
offset += size
if len(candidates) != 1:
raise ValueError('Expected one active vendor diagnostic interface')
candidate = candidates[0]
endpoints = candidate['endpoints']
incoming = [ep for ep,kind in endpoints if kind == 2 and ep&0x80 and ep&0x0f]
outgoing = [ep for ep,kind in endpoints if kind == 2 and not ep&0x80 and ep&0x0f]
if candidate['count'] != 2 or len(endpoints) != 2 or len(incoming) != 1 or len(outgoing) != 1:
raise ValueError('Expected one bulk IN and one bulk OUT endpoint')
return {'interface':candidate['interface'], 'in':incoming[0], 'out':outgoing[0]}
def device_layout(device, sysfs=Path('/sys/bus/usb/devices')):
device = Path(device)
for entry in sysfs.iterdir():
try:
path = Path('/dev/bus/usb')/f'{int((entry/"busnum").read_text()):03d}'/f'{int((entry/"devnum").read_text()):03d}'
except (FileNotFoundError,NotADirectoryError):
continue
if path == device:
identity = [(entry/k).read_text().strip() for k in ('idVendor','idProduct','serial')]
if identity != ['1d6b','0104','TUNDRA-DIAG']:
raise ValueError('Selected USB device is not the Tundra diagnostic')
return parse_endpoints((entry/'descriptors').read_bytes(), int((entry/'bConfigurationValue').read_text()))
raise RuntimeError('USB diagnostic disappeared; select its current device path')
def exchange(device, commands=('status','version','mounts')):
commands = tuple(commands)
for command in commands:
if not command.isascii() or not command.isalpha() or len(command)>60:
raise ValueError('Invalid diagnostic command')
layout = device_layout(device)
libc=ctypes.CDLL(None,use_errno=True)
libc.ioctl.argtypes = [ctypes.c_int,ctypes.c_ulong,ctypes.c_void_p]
libc.ioctl.restype = ctypes.c_int
ioctl_bulk=0xc0005502 | (ctypes.sizeof(Bulk)<<16)
results={}
with Path(device).open('r+b',buffering=0) as stream:
fcntl.ioctl(stream,0x8004550f,struct.pack('I',layout['interface'])) # USBDEVFS_CLAIMINTERFACE
def bulk(ep,payload=None):
buf=ctypes.create_string_buffer(payload,len(payload)) if payload else ctypes.create_string_buffer(4096)
request=Bulk(ep,len(buf),3000,ctypes.addressof(buf))
count=libc.ioctl(stream.fileno(),ioctl_bulk,ctypes.byref(request))
if count<0:raise OSError(ctypes.get_errno(),f'USB transfer endpoint 0x{ep:02x}')
if payload is not None and count != len(payload):
raise RuntimeError('Incomplete USB command write')
return buf.raw[:count]
for command in commands:
bulk(layout['out'],(command+'\n').encode())
response=b''
deadline=time.monotonic()+6
while not response.endswith(b'\nEND\n'):
if time.monotonic() >= deadline:
raise TimeoutError('Diagnostic response did not finish')
chunk=bulk(layout['in'])
if not chunk:
raise RuntimeError('Empty USB response')
response+=chunk
if len(response)>8192:raise RuntimeError('Invalid oversized response')
results[command]=response.decode()
return results