etc.git


etc

~ scripts and random things
git clone https://github.com/djnnvx/etc.git
log | files | refs | archive

bluetoothinator.py (8661B)

      1 !/usr/bin/env python3
      2 """Connect to a Bluetooth device and get a shell to poke at it.
      3 
      4 Commands:
      5   sdp                 browse services
      6   rfcomm <ch> [hex]   open a channel, read, optionally send bytes first
      7   l2cap <psm>         try an L2CAP connect
      8   scan                classic inquiry
      9   info                bond and connection state
     10   name                remote name request
     11   class [hex]         show or set our class of device
     12   myname [str]        show or set our name
     13   myaddr [addr]       show or set our BD_ADDR
     14   fresh               take a new random BD_ADDR
     15   id                  show our full identity
     16   reset               restore the adapter identity we started with
     17   pair | unpair
     18   raw <cmd...>        run a bluetoothctl command
     19   q
     20 """
     21 import argparse, ctypes, errno, os, random, re, select, shlex, socket, struct, subprocess, sys, time
     22 
     23 try:
     24     import readline
     25 except ImportError:  # free-threaded builds ship without it
     26     readline = None
     27 
     28 AF_BLUETOOTH, RFCOMM, L2CAP = 31, 3, 0
     29 HCI = "hci0"
     30 CMDS = "sdp rfcomm l2cap scan info name class myname myaddr fresh id reset pair unpair raw q".split()
     31 HISTFILE = os.path.expanduser("~/.bluetoothinator_history")
     32 libc = ctypes.CDLL("libc.so.6", use_errno=True)
     33 
     34 
     35 def sh(cmd, t=90):
     36     try:
     37         r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=t)
     38         return re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", r.stdout + r.stderr).strip()
     39     except subprocess.TimeoutExpired:
     40         return "<timeout>"
     41 
     42 
     43 def ident():
     44     cfg = sh(f"hciconfig -a {HCI}")
     45     g = lambda r: (re.search(r, cfg) or [None, "?"])[1]
     46     return g(r"BD Address: (\S+)"), g(r"Name: '(.*)'"), g(r"Class: (0x[0-9a-f]+)"), g(r"Device Class: (.+)")
     47 
     48 
     49 def idline():
     50     return "{} '{}' {} {}".format(*ident())
     51 
     52 
     53 def hexeq(a, b):
     54     try:
     55         return int(a, 16) == int(b, 16)
     56     except ValueError:
     57         return False
     58 
     59 
     60 def rname(bd):
     61     return sh(f"hcitool -i {HCI} name {bd}", 15) or "no name response"
     62 
     63 
     64 def up():
     65     for _ in range(10):
     66         if "UP RUNNING" in sh(f"hciconfig {HCI}"):
     67             return True
     68         sh(f"hciconfig {HCI} up")
     69         time.sleep(0.5)
     70     return False
     71 
     72 
     73 def set_addr(addr, cod):
     74     """Must start from an adapter that is up. Cycling a down adapter drops the
     75     pending address and the controller comes back on its factory one."""
     76     if not up():
     77         return "adapter will not come up, refusing to touch the address"
     78     idx = re.sub(r"\D", "", HCI) or "0"
     79     last = ""
     80     for _ in range(3):
     81         # ponytail: sleeps tuned against MediaTek, stretch them if a slower dongle races
     82         last = sh(f"hciconfig {HCI} reset; sleep 3; hciconfig {HCI} down; sleep 1; "
     83                   f"btmgmt --index {idx} public-addr {addr}; hciconfig {HCI} up; sleep 2", 40)
     84         if ident()[0].upper() == addr.upper():
     85             up()
     86             if cod and cod != "?":
     87                 sh(f"hciconfig {HCI} class {cod}")
     88             # bluetoothd re-init can silently put the factory address back.
     89             time.sleep(3)
     90             if ident()[0].upper() != addr.upper():
     91                 continue
     92             return f"{addr} ok"
     93     up()
     94     return f"failed to set {addr}, now {ident()[0]} ({last})"
     95 
     96 
     97 def bdbytes(bd):
     98     return bytes(int(x, 16) for x in reversed(bd.split(":")))
     99 
    100 
    101 def connect(sock, sa, t):
    102     buf = ctypes.create_string_buffer(sa, len(sa))
    103     if libc.connect(sock.fileno(), buf, len(sa)) != 0:
    104         e = ctypes.get_errno()
    105         if e != errno.EINPROGRESS:
    106             return errno.errorcode.get(e, str(e))
    107         if not select.select([], [sock], [], t)[1]:
    108             return "TIMEOUT"
    109         if (err := sock.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR)):
    110             return errno.errorcode.get(err, str(err))
    111     return "OPEN"
    112 
    113 
    114 def dump(data):
    115     for i in range(0, len(data), 16):
    116         c = data[i:i + 16]
    117         txt = "".join(chr(b) if 32 <= b < 127 else "." for b in c)
    118         print(f"    {i:04x}  {c.hex(' '):<47}  {txt}")
    119 
    120 
    121 def rfcomm(bd, ch, send=b"", t=8.0):
    122     s = socket.socket(AF_BLUETOOTH, socket.SOCK_STREAM, RFCOMM)
    123     s.setblocking(False)
    124     try:
    125         r = connect(s, struct.pack("<H6sBx", AF_BLUETOOTH, bdbytes(bd), ch), t)
    126         print(f"  connect: {r}")
    127         if r != "OPEN":
    128             return
    129         if send:
    130             s.setblocking(True)
    131             s.send(send)
    132             s.setblocking(False)
    133             print(f"  sent {len(send)}B")
    134         data, end = b"", time.time() + 4
    135         while time.time() < end:
    136             if not select.select([s], [], [], max(0, end - time.time()))[0]:
    137                 break
    138             d = s.recv(4096)
    139             if not d:
    140                 break
    141             data += d
    142         print(f"  got {len(data)}B")
    143         if data:
    144             dump(data)
    145     except OSError as e:
    146         print(f"  {e}")
    147     finally:
    148         s.close()
    149 
    150 
    151 def l2cap(bd, psm, t=5.0):
    152     s = socket.socket(AF_BLUETOOTH, socket.SOCK_SEQPACKET, L2CAP)
    153     s.setblocking(False)
    154     try:
    155         print(f"  connect: {connect(s, struct.pack('<HH6sHB', AF_BLUETOOTH, psm, bdbytes(bd), 0, 0), t)}")
    156     finally:
    157         s.close()
    158 
    159 
    160 def history():
    161     if not readline:
    162         return
    163     try:
    164         readline.read_history_file(HISTFILE)
    165     except OSError:
    166         pass
    167     readline.set_completer(lambda t, i: ([c for c in CMDS if c.startswith(t)] + [None])[i])
    168     readline.parse_and_bind("tab: complete")
    169 
    170 
    171 
    172 def main():
    173     global HCI
    174     a = argparse.ArgumentParser()
    175     a.add_argument("bdaddr", nargs="?")
    176     a.add_argument("--class", dest="cod", default="0x240418")
    177     a.add_argument("--name")
    178     a.add_argument("--addr")
    179     a.add_argument("--dev", default="hci0")
    180     o = a.parse_args()
    181     if not o.bdaddr:
    182         a.error("bdaddr required")
    183     HCI = o.dev
    184     bd = o.bdaddr
    185     orig = ident()
    186 
    187     if o.addr:
    188         print(set_addr(o.addr, o.cod))
    189     if o.name:
    190         sh(f"bluetoothctl system-alias {shlex.quote(o.name)}")
    191     sh(f"hciconfig {HCI} class {o.cod}")
    192     time.sleep(1)
    193     addr, name, got, dev = ident()
    194     if not hexeq(got, o.cod):
    195         print(f"warning: class is {got}, not {o.cod}")
    196 
    197     print(__doc__)
    198     print(f"us     {addr}  '{name}'  {got} {dev}")
    199     print(f"target {bd}  {rname(bd)}\n")
    200     history()
    201 
    202     while True:
    203         try:
    204             line = input(f"{bd}> ").strip()
    205         except (EOFError, KeyboardInterrupt):
    206             break
    207         if not line:
    208             continue
    209         cmd, *args = line.split()
    210 
    211         if cmd in ("q", "quit", "exit"):
    212             break
    213         elif cmd == "sdp":
    214             out = sh(f"sdptool -i {HCI} browse {bd}")
    215             for m in re.finditer(r"Service Name: (.+)|Channel: (\d+)|PSM: (\d+)", out):
    216                 print("  " + m.group(0))
    217         elif cmd == "rfcomm" and args:
    218             rfcomm(bd, int(args[0]), bytes.fromhex(args[1]) if len(args) > 1 else b"")
    219         elif cmd == "l2cap" and args:
    220             l2cap(bd, int(args[0], 0))
    221         elif cmd == "scan":
    222             print(sh(f"hcitool -i {HCI} scan --flush", 40) or "  nothing found")
    223         elif cmd == "info":
    224             print(sh(f"bluetoothctl info {bd}"))
    225         elif cmd == "name":
    226             print(f"  {rname(bd)}")
    227         elif cmd == "class":
    228             if args:
    229                 sh(f"hciconfig {HCI} class {args[0]}")
    230             print("  " + sh(f"hciconfig -a {HCI} | grep -E 'Class:|Device Class:'"))
    231         elif cmd == "myname":
    232             if args:
    233                 sh(f"bluetoothctl system-alias {shlex.quote(' '.join(args))}")
    234             print(f"  {ident()[1]}")
    235         elif cmd == "myaddr":
    236             if args:
    237                 print("  " + set_addr(args[0], ident()[2]))
    238             print(f"  {ident()[0]}")
    239         elif cmd == "fresh":
    240             new = "DE:AD:" + ":".join(f"{random.randrange(256):02X}" for _ in range(4))
    241             print("  " + set_addr(new, ident()[2]))
    242             print(f"  {ident()[0]}")
    243         elif cmd == "id":
    244             print("  " + idline())
    245         elif cmd == "reset":
    246             if ident()[0] != orig[0]:
    247                 set_addr(orig[0], orig[2])
    248             sh("bluetoothctl reset-alias")
    249             sh(f"hciconfig {HCI} class {orig[2]}")
    250             time.sleep(1)
    251             print("  " + idline())
    252         elif cmd == "pair":
    253             print(sh(f"bluetoothctl --timeout 25 -- pair {bd}", 40))
    254         elif cmd == "unpair":
    255             print(sh(f"bluetoothctl remove {bd}"))
    256         elif cmd == "raw" and args:
    257             print(sh(f"bluetoothctl {' '.join(args)}"))
    258         else:
    259             print("  ?")
    260 
    261     if readline:
    262         try:
    263             readline.write_history_file(HISTFILE)
    264         except OSError:
    265             pass
    266 
    267 
    268 if __name__ == "__main__":
    269     sys.exit(main())