Ruuvi air BLE 5 on raspberry pi reading E1 format

using the proper BLE 5v dongle you can read the ruuvi air E1 format on a raspberry pi2 ,3,4,5 (must be on pi OS >6.1. ie 6.18 works, 6.1 does not)

!! not all ble dongle really support full BLE5 !!
UGREEN (UGREEN Bluetooth 6.0 Adapter Dongle for PC, USB Bluetooth Stick for Mouse | Only Win 11/10/8.1, plug & play, high performance, low latency) does, TPLINK (TP-Link UB600 Bluetooth 6.0 Adapter Dongle for PC, USB Bluetooth Stick for Mouse/Keyboard/Headphone/Mobile Phone/Controller, Plug & Play, Windows 11/10/8.1/7 | USB WiFi adapter for PC plug & play installation stable connection. Compatible with Windows and macO) does not

Karl

the code produces something like this:

sudo python3 ruuviPrint.py 2
hci2: adapter supports BT5 extended advertising -> E1 receivable
listening on hci2 for ruuvi df5 / df6 / E1 - ctrl-c to stop
11:41:30 CB:25:B7:8F:BA:BE rssi: -26  df6   temp: 23.43C  hum: 47.2%  press: 97934Pa  PM2.5:  2.1  CO2: 520ppm  VOC: 95  NOx:  2  cnt:163
11:41:32 CB:25:B7:8F:BA:BE rssi: -53  E1    temp: 23.43C  hum: 47.2%  press: 97933Pa  PM1/2.5/4/10:  1.0/  2.1/  3.0/  3.5  CO2: 519ppm  VOC: 95  NOx:  2  lumi:   n/alx  seq:1092260
11:41:51 CB:25:B7:8F:BA:BE rssi: -53  E1    temp: 23.43C  hum: 47.2%  press: 97934Pa  PM1/2.5/4/10:  0.9/  1.9/  2.8/  3.2  CO2: 519ppm  VOC: 95  NOx:  2  lumi:   n/alx  seq:1092276
11:41:57 C2:96:D3:FB:D1:F8 rssi: -71  df5   temp: 24.20C  hum: 46.9%  press: 97877Pa  accXYZ:   28/  -32/  992mg  batt:2985mV  moves:97  seq:9571
11:41:59 C1:68:AC:83:13:FD rssi: -79  df5   temp: 22.46C  hum: 52.8%  press: 97959Pa  accXYZ:  108/  -16/ 1004mg  batt:2795mV  moves:37  seq:9708
11:42:02 DE:B1:4A:8B:92:BC rssi: -63  df5   temp: 21.57C  hum: 52.0%  press: 97966Pa  accXYZ:  -48/   24/ 1008mg  batt:2905mV  moves:17  seq:9216
11:42:04 E8:C3:A8:C9:7A:02 rssi: -75  E1    temp: 23.68C  hum: 47.1%  press: 97894Pa  PM1/2.5/4/10:  0.3/  0.7/  1.0/  1.1  CO2: 541ppm  VOC:101  NOx:  1  lumi:   n/alx  seq:4488447
11:42:09 CB:25:B7:8F:BA:BE rssi: -30  df6   temp: 23.43C  hum: 47.2%  press: 97933Pa  PM2.5:  1.8  CO2: 518ppm  VOC: 95  NOx:  2  cnt:199
11:42:15 CB:25:B7:8F:BA:BE rs

here the python code:

#!/usr/bin/python3
# -*- coding: utf-8 -*-
"""Very basic Ruuvi reader: listens on one BLE adapter and PRINTS every Ruuvi
advertisement it hears - Bluetooth 4 (legacy) AND Bluetooth 5 (extended) frames:

    data format 3  (RuuviTag RAWv1, old firmware)
    data format 5  (RuuviTag RAWv2:  temp, hum, pressure, acceleration, battery, ...)
    data format 6  (Ruuvi Air, compact legacy adv)
    data format E1 (Ruuvi Air, FULL data set via BT5 extended adv - needs an
                    extended-advertising capable adapter, e.g. the UGREEN 33fa:0012)
    anything else from a Ruuvi (encrypted df8, future formats) is printed as raw hex,
    so EVERY ruuvi in range shows up.

Run on any linux box with python 3.3+ (STANDALONE - stdlib only, no other files needed):
    sudo python3 ruuviPrint.py            # hci0
    sudo python3 ruuviPrint.py 2          # hci2
Stop with ctrl-c. If another program already scans on that adapter the tool just
listens along (events are copied to every raw socket); standalone it enables scanning
itself (extended when the adapter supports it, else legacy).


KarlWachs July 26, 2026 

MIT lisence 
The software is provided "as is"


"""

import sys, time, struct, socket

OGF_LE     = 0x08
SOL_HCI    = 0
HCI_FILTER = 2


def hciOpen(devId):
	"""raw HCI socket bound to hci<devId>, filter = all events (16-byte struct hci_ufilter)"""
	sock = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_RAW, socket.BTPROTO_HCI)
	sock.bind((devId,))
	#                       type_mask: bit4=HCI_EVENT_PKT   event_mask: all         opcode + pad
	flt = struct.pack("<LLLHH", 1 << 0x04, 0xFFFFFFFF, 0xFFFFFFFF, 0, 0)
	sock.setsockopt(SOL_HCI, HCI_FILTER, flt)
	return sock


def hciCmd(sock, ogf, ocf, params=b""):
	"""sends one HCI command packet (fire and forget)"""
	sock.send(struct.pack("<BHB", 0x01, (ogf << 10) | ocf, len(params)) + params)


def s16(b, i):	v = (b[i] << 8) | b[i+1];	return v - 65536 if v > 32767 else v
def u16(b, i):	return (b[i] << 8) | b[i+1]
def u24(b, i):	return (b[i] << 16) | (b[i+1] << 8) | b[i+2]


def decodeRuuvi(mac, rssi, mfg):
	"""mfg = manufacturer-data bytes AFTER the 0499 company id; prints one line"""
	tt = time.strftime("%H:%M:%S")
	if len(mfg) < 1: return
	df = mfg[0]
	if df == 0x03 and len(mfg) >= 14:				# RuuviTag RAWv1 (old firmware)
		temp = (mfg[2] & 0x7F) + mfg[3]/100.
		if mfg[2] & 0x80: temp = -temp
		print("{} {} rssi:{:4d}  df3   temp:{:6.2f}C  hum:{:5.1f}%  press:{:6d}Pa  accXYZ:{:5d}/{:5d}/{:5d}mg  batt:{}mV".format(
			tt, mac, rssi, temp, mfg[1]*0.5, u16(mfg,4)+50000,
			s16(mfg,6), s16(mfg,8), s16(mfg,10), u16(mfg,12)))
	elif df == 0x05 and len(mfg) >= 18:				# RuuviTag RAWv2
		battmV = 1600 + ((u16(mfg,13) >> 5) & 0x7FF)
		print("{} {} rssi:{:4d}  df5   temp:{:6.2f}C  hum:{:5.1f}%  press:{:6d}Pa  accXYZ:{:5d}/{:5d}/{:5d}mg  batt:{}mV  moves:{}  seq:{}".format(
			tt, mac, rssi, s16(mfg,1)*0.005, u16(mfg,3)*0.0025, u16(mfg,5)+50000,
			s16(mfg,7), s16(mfg,9), s16(mfg,11), battmV, mfg[15], u16(mfg,16)))
	elif df == 0x06 and len(mfg) >= 17:				# Ruuvi Air compact; VOC/NOx are 9 bit: (byte<<1)+flag bit
		flags = mfg[16]
		print("{} {} rssi:{:4d}  df6   temp:{:6.2f}C  hum:{:5.1f}%  press:{:6d}Pa  PM2.5:{:5.1f}  CO2:{:4d}ppm  VOC:{:3d}  NOx:{:3d}  cnt:{}".format(
			tt, mac, rssi, s16(mfg,1)*0.005, u16(mfg,3)*0.0025, u16(mfg,5)+50000,
			u16(mfg,7)*0.1, u16(mfg,9), (mfg[11]<<1)|((flags>>6)&1), (mfg[12]<<1)|((flags>>7)&1), mfg[15]))
	elif df == 0xE1 and len(mfg) >= 40:				# Ruuvi Air FULL (BT5 extended)
		flags = mfg[28]
		lumi  = "   n/a" if u24(mfg,19) == 0xFFFFFF else "{:6.0f}".format(u24(mfg,19)*0.01)
		print("{} {} rssi:{:4d}  E1    temp:{:6.2f}C  hum:{:5.1f}%  press:{:6d}Pa  PM1/2.5/4/10:{:5.1f}/{:5.1f}/{:5.1f}/{:5.1f}  CO2:{:4d}ppm  VOC:{:3d}  NOx:{:3d}  lumi:{}lx  seq:{}".format(
			tt, mac, rssi, s16(mfg,1)*0.005, u16(mfg,3)*0.0025, u16(mfg,5)+50000,
			u16(mfg,7)*0.1, u16(mfg,9)*0.1, u16(mfg,11)*0.1, u16(mfg,13)*0.1,
			u16(mfg,15), (mfg[17]<<1)|((flags>>6)&1), (mfg[18]<<1)|((flags>>7)&1), lumi, u24(mfg,25)))
	else:											# unknown/encrypted format - still show it
		print("{} {} rssi:{:4d}  df:{:02X} raw: {}".format(tt, mac, rssi, df, "".join("{:02X}".format(c) for c in mfg)))


def ruuviFromAdvData(mac, rssi, data):
	"""walks the AD sections; ruuvi = manufacturer data (FF) with company id 0499"""
	pos = 0
	while pos + 1 < len(data):
		ll = data[pos]
		if ll == 0: break
		if data[pos+1] == 0xFF and ll >= 3 and data[pos+2] == 0x99 and data[pos+3] == 0x04:
			decodeRuuvi(mac, rssi, bytes(data[pos+4:pos+1+ll]))
		pos += 1 + ll


def checkBT5(sock):
	"""True when the adapter supports BT5 extended advertising (LE feature bit 12) -
	only then E1 frames are receivable; the label on the box means nothing."""
	try:
		sock.settimeout(0.8)
		hciCmd(sock, OGF_LE, 0x0003)						# LE Read Local Supported Features
		t0 = time.time()
		while time.time() - t0 < 1.5:
			ev = bytearray(sock.recv(512))
			if len(ev) >= 15 and ev[1] == 0x0E and (ev[4] | (ev[5] << 8)) == 0x2003 and ev[6] == 0:
				return bool(ev[8] & 0x10)					# feats byte1 bit4 = feature bit 12
	except Exception:	pass
	return False


def main():
	devId = int(sys.argv[1]) if len(sys.argv) > 1 else 0
	sock  = hciOpen(devId)

	isBT5 = checkBT5(sock)
	sock.settimeout(2.0)

	# best effort scan enable - statuses are not checked: if another program already scans
	# on this adapter these commands are rejected/ignored and we simply listen to its stream
	if isBT5:
		print("hci{}: adapter supports BT5 extended advertising -> E1 receivable".format(devId))
		try:	hciCmd(sock, OGF_LE, 0x0001, struct.pack("<Q", 0x000FFFFF))	# event mask incl. bit12 ext reports
		except Exception:	pass
		try:
			hciCmd(sock, OGF_LE, 0x0041, struct.pack("<BBBBHH", 0, 0, 0x01, 0x01, 0x0010, 0x0010))
			hciCmd(sock, OGF_LE, 0x0042, struct.pack("<BBHH", 0x01, 0x00, 0, 0))
		except Exception:	pass
	else:
		print("hci{}: adapter has NO BT5 extended advertising -> only legacy frames (df5/df6); E1 needs a capable dongle (e.g. UGREEN 33fa:0012)".format(devId))
		try:
			hciCmd(sock, OGF_LE, 0x000B, struct.pack("<BHHBB", 0x01, 0x0010, 0x0010, 0x00, 0x00))
			hciCmd(sock, OGF_LE, 0x000C, struct.pack("<BB", 0x01, 0x00))
		except Exception:	pass

	print("listening on hci{} for ruuvi df5 / df6{} - ctrl-c to stop".format(devId, " / E1" if isBT5 else ""))
	while True:
		try:	ev = bytearray(sock.recv(512))
		except KeyboardInterrupt:	break
		except Exception:			continue
		if len(ev) < 5 or ev[0] != 0x04 or ev[1] != 0x3E:	continue
		if ev[3] == 0x0D:								# BT5 extended advertising report(s)
			pos = 5
			for ii in range(ev[4]):
				if pos + 24 > len(ev): break
				mac     = ":".join("{:02X}".format(c) for c in reversed(ev[pos+3:pos+9]))
				rssi    = ev[pos+13] - 256 if ev[pos+13] > 127 else ev[pos+13]
				dataLen = ev[pos+23]
				ruuviFromAdvData(mac, rssi, ev[pos+24:pos+24+dataLen])
				pos += 24 + dataLen
		elif ev[3] == 0x02:								# BT4 legacy advertising report(s)
			pos = 4
			for ii in range(ev[4] if len(ev) > 4 else 0):
				if pos + 9 > len(ev): break
				mac     = ":".join("{:02X}".format(c) for c in reversed(ev[pos+2:pos+8]))
				dataLen = ev[pos+8]
				rssiPos = pos + 9 + dataLen
				rssi    = (ev[rssiPos] - 256 if ev[rssiPos] > 127 else ev[rssiPos]) if rssiPos < len(ev) else 0
				ruuviFromAdvData(mac, rssi, ev[pos+9:pos+9+dataLen])
				pos += 10 + dataLen


if __name__ == "__main__":
	main()

2 Likes

here a newer version that gives little more info and scans the HCI channels:

#!/usr/bin/python3
# -*- coding: utf-8 -*-
"""Very basic Ruuvi reader: listens on one BLE adapter and PRINTS every Ruuvi
advertisement it hears - Bluetooth 4 (legacy) AND Bluetooth 5 (extended) frames:

    data format 3  (RuuviTag RAWv1, old firmware)
    data format 5  (RuuviTag RAWv2:  temp, hum, pressure, acceleration, battery, ...)
    data format 6  (Ruuvi Air, compact legacy adv)
    data format E1 (Ruuvi Air, FULL data set via BT5 extended adv - needs an
                    extended-advertising capable adapter, e.g. the UGREEN 33fa:0012)
    anything else from a Ruuvi (encrypted df8, future formats) is printed as raw hex,
    so EVERY ruuvi in range shows up.

Run on any linux box with python 3.3+ (STANDALONE - stdlib only, no other files needed):
    sudo python3 ruuviPrint.py            # overview of ALL adapters, then use the best (BT5 first)
    sudo python3 ruuviPrint.py 2          # force hci2, no overview
Stop with ctrl-c. If another program already scans on that adapter the tool just
listens along (the kernel copies HCI events to every raw socket) - you then see
whatever THAT program's scan settings deliver: a passive or duplicate-filtered scan
yields fewer frames than this tool's own 100%-duty active scan. Standalone it enables
scanning itself (extended when the adapter supports it, else legacy).


KarlWachs July 26, 2026 

MIT lisence 
The software is provided "as is"


"""

import sys, time, struct, socket

OGF_LE     = 0x08
OGF_INFO   = 0x04
SOL_HCI    = 0
HCI_FILTER = 2


def hciOpen(devId):
	"""raw HCI socket bound to hci<devId>, filter = all events (16-byte struct hci_ufilter)"""
	sock = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_RAW, socket.BTPROTO_HCI)
	sock.bind((devId,))
	#                       type_mask: bit4=HCI_EVENT_PKT   event_mask: all         opcode + pad
	flt = struct.pack("<LLLHH", 1 << 0x04, 0xFFFFFFFF, 0xFFFFFFFF, 0, 0)
	sock.setsockopt(SOL_HCI, HCI_FILTER, flt)
	return sock


def hciCmd(sock, ogf, ocf, params=b""):
	"""sends one HCI command packet (fire and forget)"""
	sock.send(struct.pack("<BHB", 0x01, (ogf << 10) | ocf, len(params)) + params)


def s16(b, i):	v = (b[i] << 8) | b[i+1];	return v - 65536 if v > 32767 else v
def u16(b, i):	return (b[i] << 8) | b[i+1]
def u24(b, i):	return (b[i] << 16) | (b[i+1] << 8) | b[i+2]


RAWCOLUMN = 186		# decoded text is padded to this width, then "raw:<hex>" follows


def markerOr(isInvalid, marker, txt):
	"""df fields that a tag does not have carry an all-ones marker (FFFF / FFFFFF).
	Show the marker itself instead of decoding it - 65535*0.0025 = 163.8% is not a reading.
	Padded to the width of txt so the columns stay aligned."""
	if isInvalid: return marker.rjust(len(txt))
	return txt


def decodeRuuvi(mac, rssi, mfg):
	"""mfg = manufacturer-data bytes AFTER the 0499 company id; prints one line"""
	tt = time.strftime("%H:%M:%S")
	if len(mfg) < 1: return
	df  = mfg[0]
	txt = ""
	if df == 0x03 and len(mfg) >= 14:				# RuuviTag RAWv1 (old firmware)
		temp = (mfg[2] & 0x7F) + mfg[3]/100.
		if mfg[2] & 0x80: temp = -temp
		hum3   = markerOr(mfg[1] == 0xFF,      "FF",   "{:5.1f}".format(mfg[1]*0.5))
		press3 = markerOr(u16(mfg,4) == 0xFFFF, "FFFF", "{:6d}".format(u16(mfg,4)+50000))
		txt = ("{} {} rssi:{:4d}  df3   temp:{:6.2f}C  hum:{}%  press:{}Pa  accXYZ:{:5d}/{:5d}/{:5d}mg  batt:{}mV".format(
			tt, mac, rssi, temp, hum3, press3,
			s16(mfg,6), s16(mfg,8), s16(mfg,10), u16(mfg,12)))
	elif df == 0x05 and len(mfg) >= 18:				# RuuviTag RAWv2
		battmV = 1600 + ((u16(mfg,13) >> 5) & 0x7FF)
		hum   = markerOr(u16(mfg,3) == 0xFFFF, "FFFF", "{:5.1f}".format(u16(mfg,3)*0.0025))
		press = markerOr(u16(mfg,5) == 0xFFFF, "FFFF", "{:6d}".format(u16(mfg,5)+50000))
		txt = ("{} {} rssi:{:4d}  df5   temp:{:6.2f}C  hum:{}%  press:{}Pa  accXYZ:{:5d}/{:5d}/{:5d}mg  batt:{}mV  moves:{}  seq:{}".format(
			tt, mac, rssi, s16(mfg,1)*0.005, hum, press,
			s16(mfg,7), s16(mfg,9), s16(mfg,11), battmV, mfg[15], u16(mfg,16)))
	elif df == 0x06 and len(mfg) >= 17:				# Ruuvi Air compact; VOC/NOx are 9 bit: (byte<<1)+flag bit
		flags = mfg[16]
		hum   = markerOr(u16(mfg,3) == 0xFFFF, "FFFF", "{:5.1f}".format(u16(mfg,3)*0.0025))
		press = markerOr(u16(mfg,5) == 0xFFFF, "FFFF", "{:6d}".format(u16(mfg,5)+50000))
		txt = ("{} {} rssi:{:4d}  df6   temp:{:6.2f}C  hum:{}%  press:{}Pa  PM2.5:{:5.1f}  CO2:{:4d}ppm  VOC:{:3d}  NOx:{:3d}  cnt:{}".format(
			tt, mac, rssi, s16(mfg,1)*0.005, hum, press,
			u16(mfg,7)*0.1, u16(mfg,9), (mfg[11]<<1)|((flags>>6)&1), (mfg[12]<<1)|((flags>>7)&1), mfg[15]))
	elif df == 0xE1 and len(mfg) >= 40:				# Ruuvi Air FULL (BT5 extended)
		flags = mfg[28]
		hum   = markerOr(u16(mfg,3)  == 0xFFFF,   "FFFF",   "{:5.1f}".format(u16(mfg,3)*0.0025))
		press = markerOr(u16(mfg,5)  == 0xFFFF,   "FFFF",   "{:6d}".format(u16(mfg,5)+50000))
		co2   = markerOr(u16(mfg,15) == 0xFFFF,   "FFFF",   "{:4d}".format(u16(mfg,15)))
		lumi  = markerOr(u24(mfg,19) == 0xFFFFFF, "FFFFFF", "{:6.0f}".format(u24(mfg,19)*0.01))
		pm    = "/".join(markerOr(u16(mfg,i) == 0xFFFF, "FFFF", "{:5.1f}".format(u16(mfg,i)*0.1)) for i in (7,9,11,13))
		voc9  = (mfg[17]<<1)|((flags>>6)&1)
		nox9  = (mfg[18]<<1)|((flags>>7)&1)
		txt = ("{} {} rssi:{:4d}  E1    temp:{:6.2f}C  hum:{}%  press:{}Pa  PM1/2.5/4/10:{}  CO2:{}ppm  VOC:{}  NOx:{}  lumi:{}lx  seq:{}".format(
			tt, mac, rssi, s16(mfg,1)*0.005, hum, press, pm, co2,
			markerOr(voc9 == 0x1FF, "1FF", "{:3d}".format(voc9)),
			markerOr(nox9 == 0x1FF, "1FF", "{:3d}".format(nox9)), lumi, u24(mfg,25)))
	else:											# unknown/encrypted format - still show it
		txt = "{} {} rssi:{:4d}  df:{:02X} <not decoded>".format(tt, mac, rssi, df)

	# every line ends with the RAW manufacturer payload in a fixed column, so the hex of
	# different formats lines up under each other. FFFF in a field = that sensor is not
	# present in this tag (eg a sealed Pro with an external probe: no humidity/pressure).
	print("{}  raw:{}".format(txt.ljust(RAWCOLUMN), "".join("{:02X}".format(c) for c in mfg)))


def ruuviFromAdvData(mac, rssi, data):
	"""walks the AD sections; ruuvi = manufacturer data (FF) with company id 0499"""
	pos = 0
	while pos + 1 < len(data):
		ll = data[pos]
		if ll == 0: break
		if data[pos+1] == 0xFF and ll >= 3 and data[pos+2] == 0x99 and data[pos+3] == 0x04:
			decodeRuuvi(mac, rssi, bytes(data[pos+4:pos+1+ll]))
		pos += 1 + ll


def checkBT5(sock):
	"""True when the adapter supports BT5 extended advertising (LE feature bit 12) -
	only then E1 frames are receivable; the label on the box means nothing."""
	try:
		sock.settimeout(0.8)
		hciCmd(sock, OGF_LE, 0x0003)						# LE Read Local Supported Features
		t0 = time.time()
		while time.time() - t0 < 1.5:
			ev = bytearray(sock.recv(512))
			if len(ev) >= 15 and ev[1] == 0x0E and (ev[4] | (ev[5] << 8)) == 0x2003 and ev[6] == 0:
				return bool(ev[8] & 0x10)					# feats byte1 bit4 = feature bit 12
	except Exception:	pass
	return False



def hciReply(sock, ogf, ocf, params=b"", timeout=1.0):
	"""send a command and return the command-complete RETURN PARAMETERS (after the status
	byte), or None. Used to read the adapter properties for the overview."""
	opcode = (ogf << 10) | ocf
	try:
		sock.settimeout(timeout)
		hciCmd(sock, ogf, ocf, params)
		t0 = time.time()
		while time.time() - t0 < timeout + 0.5:
			ev = bytearray(sock.recv(512))
			#      event pkt      cmd complete      matching opcode                    status ok
			if len(ev) >= 7 and ev[1] == 0x0E and (ev[4] | (ev[5] << 8)) == opcode and ev[6] == 0:
				return ev[7:]
	except Exception:
		pass
	return None


def hciProperties(sock):
	"""mac + the parameters that matter for a scan adapter:
	   aclMTU  - real radios report ~1017-1021, CSR8510 CLONES report 310 (they scan but
	             fail LE connects, so a low value here is the fake-dongle fingerprint)
	   btVer   - HCI version byte: 6=4.0, 7=4.1, 8=4.2, 9=5.0, 10=5.1, 11=5.2, 12=5.3, 13=5.4
	   manuf   - company id of the chip maker"""
	mac, aclMTU, btVer, manuf = "?", 0, 0, 0
	r = hciReply(sock, OGF_INFO, 0x0009)					# Read BD_ADDR
	if r is not None and len(r) >= 6:
		mac = ":".join("{:02X}".format(r[i]) for i in range(5, -1, -1))
	r = hciReply(sock, OGF_INFO, 0x0005)					# Read Buffer Size
	if r is not None and len(r) >= 2:
		aclMTU = r[0] | (r[1] << 8)
	r = hciReply(sock, OGF_INFO, 0x0001)					# Read Local Version Information
	if r is not None and len(r) >= 8:
		btVer = r[0]
		manuf = r[4] | (r[5] << 8)
	return mac, aclMTU, btVer, manuf


_BTVER = {6:"4.0", 7:"4.1", 8:"4.2", 9:"5.0", 10:"5.1", 11:"5.2", 12:"5.3", 13:"5.4",
          14:"6.0", 15:"6.1"}
_MANUF = {2:"Intel", 10:"CSR/Qualcomm", 13:"TI", 15:"Broadcom", 18:"Zeevo", 72:"MediaTek",
          93:"Realtek", 2279:"Barrot"}


def btVerText(v):
	"""HCI version byte -> marketing name; unknown/newer values are shown as the raw
	number (never "?") so a new chip is still identifiable. 0 = the read failed."""
	if v in _BTVER:	return _BTVER[v]
	if v == 0:		return "n/a"
	return "v{}".format(v)


def listHCIdevices(maxDev=10):
	"""hci numbers that can actually be opened, lowest first"""
	found = []
	for n in range(maxDev):
		try:
			so = hciOpen(n)
			so.close()
			found.append(n)
		except Exception:
			pass
	return found


def pickBestHCI():
	"""No hci number given -> probe EVERY adapter and take the best one for ruuvi:
	a BT5 (extended advertising) radio first, because only that one receives the Ruuvi Air
	E1 frames; otherwise the first adapter that opens at all. Returns (devId, isBT5) or
	(None, False) when there is no usable adapter."""
	devs = listHCIdevices()
	if not devs:
		return None, False
	print("no hci number given - probing {} adapter(s)\n".format(len(devs)))
	print("   hci  mac                aclMTU  BT    chip           ruuvi formats")
	print("   ---  -----------------  ------  ----  -------------  ---------------------------")
	best, firstOK = None, None
	for n in devs:
		try:
			so = hciOpen(n)
		except Exception as e:
			print("   {:<3}  cannot open: {}".format(n, e))
			continue
		mac, aclMTU, btVer, manuf = hciProperties(so)
		bt5 = checkBT5(so)
		so.close()
		clone = "  <- CLONE? (real radios ~1021)" if 0 < aclMTU <= 400 else ""
		print("   {:<3}  {:17}  {:>6}  {:<4}  {:<13}  {}{}".format(
			n, mac, aclMTU or "?", btVerText(btVer), _MANUF.get(manuf, "id {}".format(manuf)),
			"df3/df5/df6 + E1" if bt5 else "df3/df5/df6 only", clone))
		if bt5 and best is None:	best = n
		if firstOK is None:			firstOK = n
	print("")
	if best is not None:	return best, True
	return firstOK, False


def main():
	if len(sys.argv) > 1:
		devId = int(sys.argv[1])
		sock  = hciOpen(devId)
		isBT5 = checkBT5(sock)
	else:
		devId, isBT5 = pickBestHCI()
		if devId is None:
			print("no usable bluetooth adapter found - is bluetooth enabled? (try: sudo hciconfig hci0 up)")
			return
		print("--> using hci{}{}".format(devId, " (best: BT5 capable)" if isBT5 else " (no BT5 adapter present)"))
		sock = hciOpen(devId)
	sock.settimeout(2.0)

	# best effort scan enable - statuses are not checked: if another program already scans
	# on this adapter these commands are rejected/ignored and we simply listen to its stream
	if isBT5:
		print("hci{}: adapter supports BT5 extended advertising -> E1 receivable".format(devId))
		try:	hciCmd(sock, OGF_LE, 0x0001, struct.pack("<Q", 0x000FFFFF))	# event mask incl. bit12 ext reports
		except Exception:	pass
		try:
			hciCmd(sock, OGF_LE, 0x0041, struct.pack("<BBBBHH", 0, 0, 0x01, 0x01, 0x0010, 0x0010))
			hciCmd(sock, OGF_LE, 0x0042, struct.pack("<BBHH", 0x01, 0x00, 0, 0))
		except Exception:	pass
	else:
		print("hci{}: adapter has NO BT5 extended advertising -> only legacy frames (df5/df6); E1 needs a capable dongle (e.g. UGREEN 33fa:0012)".format(devId))
		try:
			hciCmd(sock, OGF_LE, 0x000B, struct.pack("<BHHBB", 0x01, 0x0010, 0x0010, 0x00, 0x00))
			hciCmd(sock, OGF_LE, 0x000C, struct.pack("<BB", 0x01, 0x00))
		except Exception:	pass

	print("listening on hci{} for ruuvi df5 / df6{} - ctrl-c to stop".format(devId, " / E1" if isBT5 else ""))
	while True:
		try:	ev = bytearray(sock.recv(512))
		except KeyboardInterrupt:	break
		except Exception:			continue
		if len(ev) < 5 or ev[0] != 0x04 or ev[1] != 0x3E:	continue
		if ev[3] == 0x0D:								# BT5 extended advertising report(s)
			pos = 5
			for ii in range(ev[4]):
				if pos + 24 > len(ev): break
				mac     = ":".join("{:02X}".format(c) for c in reversed(ev[pos+3:pos+9]))
				rssi    = ev[pos+13] - 256 if ev[pos+13] > 127 else ev[pos+13]
				dataLen = ev[pos+23]
				ruuviFromAdvData(mac, rssi, ev[pos+24:pos+24+dataLen])
				pos += 24 + dataLen
		elif ev[3] == 0x02:								# BT4 legacy advertising report(s)
			pos = 4
			for ii in range(ev[4] if len(ev) > 4 else 0):
				if pos + 9 > len(ev): break
				mac     = ":".join("{:02X}".format(c) for c in reversed(ev[pos+2:pos+8]))
				dataLen = ev[pos+8]
				rssiPos = pos + 9 + dataLen
				rssi    = (ev[rssiPos] - 256 if ev[rssiPos] > 127 else ev[rssiPos]) if rssiPos < len(ev) else 0
				ruuviFromAdvData(mac, rssi, ev[pos+9:pos+9+dataLen])
				pos += 10 + dataLen


if __name__ == "__main__":
	main()

prints something like this

udo python3 ruuviPrint.py 
no hci number given - probing 3 adapter(s)

   hci  mac                aclMTU  BT    chip           ruuvi formats
   ---  -----------------  ------  ----  -------------  ---------------------------
   0    00:1A:7D:DA:71:11     310  4.0   CSR/Qualcomm   df3/df5/df6 only  <- CLONE? (real radios ~1021)
   1    04:7F:0E:04:14:56     679  6.0   Barrot         df3/df5/df6 + E1
   2    B8:27:EB:9D:3A:63    1021  4.1   Broadcom       df3/df5/df6 only

--> using hci1 (best: BT5 capable)
hci1: adapter supports BT5 extended advertising -> E1 receivable
listening on hci1 for ruuvi df5 / df6 / E1 - ctrl-c to stop
13:00:43 CB:25:B7:8F:BA:BE rssi: -53  E1    temp: 23.50C  hum: 47.3%  press: 99278Pa  PM1/2.5/4/10:  0.5/  1.1/  1.7/  1.9  CO2: 671ppm  VOC:132  NOx:  1  lumi:FFFFFFlx  seq:1353680       raw:E1125B49E4C07E0005000B00110013029F4200FFFFFFFFFFFF14A7D0BCFFFFFFFFFFCB25B78FBABE
13:00:47 DE:B1:4A:8B:92:BC rssi: -61  df5   temp: 21.92C  hum: 50.3%  press: 99317Pa  accXYZ:  -48/   24/ 1008mg  batt:2923mV  moves:17  seq:18839                                          raw:0511204EA4C0A5FFD0001803F0A576114997DEB14A8B92BC
13:00:52 C1:68:AC:83:13:FD rssi: -77  df5   temp: 23.03C  hum: 52.4%  press: 99303Pa  accXYZ:  112/  -24/ 1000mg  batt:2802mV  moves:37  seq:46873                                          raw:0511FE51CEC0970070FFE803E8965625B719C168AC8313FD
13:00:59 CB:25:B7:8F:BA:BE rssi: -23  df6   temp: 23.50C  hum: 47.3%  press: 99279Pa  PM2.5:  1.1  CO2: 668ppm  VOC:132  NOx:  1  cnt:224                                                   raw:06125B49E4C07F000B029C4200FFFFE0948FBABE
13:01:00 CB:25:B7:8F:BA:BE rssi: -36  df6   temp: 23.50C  hum: 47.3%  press: 99279Pa  PM2.5:  1.1  CO2: 668ppm  VOC:132  NOx:  1  cnt:224                                                   raw:06125B49E4C07F000B029C4200FFFFE0948FBABE
13:01:06 C5:5F:FA:70:F9:69 rssi: -81  df5   temp: 30.95C  hum: 46.6%  press: 99239Pa  accXYZ: 1076/  -44/  148mg  batt:2623mV  moves:251  seq:33986                                         raw:05182D48D9C0570434FFD400947FF6FB84C2C55FFA70F969
13:01:07 CB:25:B7:8F:BA:BE rssi: -53  E1    temp: 23.50C  hum: 47.3%  press: 99278Pa  PM1/2.5/4/10:  0.5/  1.1/  1.5/  1.8  CO2: 670ppm  VOC:132  NOx:  1  lumi:FFFFFFlx  seq:1353704       raw:E1125B49E4C07E0005000B000F0012029E4200FFFFFFFFFFFF14A7E8BCFFFFFFFFFFCB25B78FBABE
13:01:09 CB:25:B7:8F:BA:BE rssi: -53  E1    temp: 23.50C  hum: 47.3%  press: 99278Pa  PM1/2.5/4/10:  0.5/  1.1/  1.5/  1.8  CO2: 670ppm  VOC:132  NOx:  1  lumi:FFFFFFlx  seq:1353705       raw:E1125B49E4C07E0005000B000F0012029E4200FFFFFFFFFFFF14A7E9BCFFFFFFFFFFCB25B78FBABE
13:01:13 D1:FC:38:C4:57:75 rssi: -62  df5   temp: 23.53C  hum: 51.8%  press: 99218Pa  accXYZ:   28/  -12/ 1004mg  batt:2039mV  moves:28  seq:25821                                          raw:05126150E2C042001CFFF403EC36F61C64DDD1FC38C45775
13:01:15 CB:25:B7:8F:BA:BE rssi: -23  df6   temp: 23.50C  hum: 47.3%  press: 99278Pa  PM2.5:  1.1  CO2: 670ppm  VOC:132  NOx:  1  cnt:239                                                   raw:06125B49E8C07E000B029E4200FFFFEF948FBABE
13:01:22 CB:25:B7:8F:BA:BE rssi: -36  df6   temp: 23.50C  hum: 47.3%  press: 99278Pa  PM2.5:  1.1  CO2: 667ppm  VOC:132  NOx:  1  cnt:246                                                   raw:06125B49E8C07E000B029B4200FFFFF6948FBABE
13:01:27 CB:25:B7:8F:BA:BE rssi: -36  df6   temp: 23.50C  hum: 47.3%  press: 99278Pa  PM2.5:  1.1  CO2: 666ppm  VOC:132  NOx:  1  cnt:252                                                   raw:06125B49E8C07E000B029A4200FFFFFC948FBABE
13:01:34 CB:25:B7:8F:BA:BE rssi: -53  E1    temp: 23.50C  hum: 47.3%  press: 99278Pa  PM1/2.5/4/10:  0.4/  1.0/  1.4/  1.6  CO2: 663ppm  VOC:132  NOx:  1  lumi:FFFFFFlx  seq:1353731       raw:E1125B49E8C07E0004000A000E001002974200FFFFFFFFFFFF14A803BCFFFFFFFFFFCB25B78FBABE
13:01:35 E2:9C:6B:AF:4A:5D rssi: -80  df6   temp: 24.03C  hum: 48.1%  press: 99289Pa  PM2.5:  1.1  CO2: 655ppm  VOC:137  NOx:  1  cnt:10                                                    raw:0612C64B34C089000B028F4400FFFF0AD4AF4A5D
13:01:42 CB:25:B7:8F:BA:BE rssi: -53  E1    temp: 23.50C  hum: 47.3%  press: 99277Pa  PM1/2.5/4/10:  0.4/  1.0/  1.4/  1.6  CO2: 667ppm  VOC:132  NOx:  1  lumi:FFFFFFlx  seq:1353739       raw:E1125B49ECC07D0004000A000E0010029B4200FFFFFFFFFFFF14A80BBCFFFFFFFFFFCB25B78FBABE
13:01:44 FA:89:7C:4B:75:B2 rssi:  -2  df5   temp: 24.30C  hum: FFFF%  press:  FFFFPa  accXYZ:   16/    4/  988mg  batt:3213mV  moves:76  seq:26320                                          raw:0512FBFFFFFFFF0010000403DCC9B64C66D0FA897C4B75B2
13:01:48 E2:9C:6B:AF:4A:5D rssi: -80  df6   temp: 24.03C  hum: 48.1%  press: 99289Pa  PM2.5:  1.1  CO2: 653ppm  VOC:137  NOx:  1  cnt:23                                                    raw:0612C64B34C089000B028D4400FFFF17D4AF4A5D

the FA:89:7C:4B:75:B2 is a ruuvitag w remote temp sensor, does not supply humidity and pressure.

the CB:25:B7:8F:BA:BE is a ruuvi Air, sends 2 different packages E1 (BLE5) and df6 (BLE4)

1 Like
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
####################
# qualifyDongleStandalone.py - decide WHAT a bluetooth adapter is actually good for
#
# Standalone tool: ONE file, python3 standard library only, no other program, daemon, library or
# server involved. Copy it to any linux box with bluetooth and run it.
#
# It answers the buying/deployment question: given this dongle, which of the four BLE jobs can it
# really do?
#    scan-BLE4        read ordinary (legacy) BLE advertisements, continuously
#    scan-BLE4+BLE5   do that AND receive BT5 extended advertisements on the SAME radio
#    BLE5-listener    receive BT5 extended advertisements (eg Ruuvi Air E1)
#    broadcast        transmit legacy advertisements (iBeacon/Eddystone style)
#    connect          open LE (GATT/ATT) connections to a tag
#
# Nothing here trusts what the adapter CLAIMS. LE feature bit 12 is set by dongles that deliver
# zero extended reports (TP-Link 18:69:45), and dongles that pass the extended test can be useless
# as scanners (Barrot 04:7F:0E: 2.6 reports/s and legacy commands rejected with 0x0C). Only
# delivered packets count.
#
# phases per adapter:
#    1 fingerprint     hciconfig (bus, OUI, ACL/SCO MTU) + sysfs VID:PID + kernel
#    2 health          comes UP after a reset, ACL MTU != 0
#                      (ACL MTU 0:0 = the "binds but never finishes HCI init" failure on old kernels)
#    3 claimed         LE Read Local Supported Features bit 12 - recorded, never trusted
#    4 extended        reset -> event mask (bit12!) -> clear -> ext params -> enable, count 0x0D,
#                      SPLIT by event-type bit 4 (legacy PDU): a BT5 controller reports the ordinary
#                      BLE4 advs in extended mode too, so this says whether ONE radio can serve as
#                      BLE4 scanner AND BLE5 listener at the same time
#    5 legacy          reset -> legacy params -> enable, count 0x02, and whether the commands
#                      were even ACCEPTED (0x0C Command Disallowed = not a scanner)
#    6 advertising     set adv params/data/enable - a radio that cannot advertise cannot broadcast
#    7 connect         OPTIONAL (connect=<MAC>): ATT (L2CAP CID 4) connections to a real tag,
#                      repeated (tries=N) - one lucky connect proves nothing, the ENOSYS problem
#                      looks like "works, but only on the 3rd attempt after 30 s"
#
# Every measuring phase also profiles PERFORMANCE, not just the average rate: per-second buckets
# and the longest silence (catches a radio that delivers one burst and then stops - an average
# hides that completely), plus the rssi distribution as a sensitivity/range proxy.
#
# usage:
#    sudo python3 qualifyDongleStandalone.py                     all adapters, 10 s per phase
#    sudo python3 qualifyDongleStandalone.py 20                  20 s per phase
#    sudo python3 qualifyDongleStandalone.py 10 hci1             only hci1
#    sudo python3 qualifyDongleStandalone.py 10 connect=C6:79:FA:75:BF:0F   + connect test (3 tries)
#    sudo python3 qualifyDongleStandalone.py 10 connect=C6:.. tries=10       + 10 connect attempts
#    sudo python3 qualifyDongleStandalone.py 10 connect=C6:.. addrType=public  force public
#                                                                (default: derived from the address)
#    sudo python3 qualifyDongleStandalone.py 10 catalogue=/tmp/x.json  catalogue file to append to
#    sudo python3 qualifyDongleStandalone.py 10 catalogue=none         no catalogue
#    sudo python3 qualifyDongleStandalone.py 10 json=/tmp/result.json  machine readable result
#
# IMPORTANT: nothing else may be using the radios while this runs - another scanner keeps
# reconfiguring them and every number here becomes meaningless. Typically:
#    sudo systemctl stop bluetooth        (and stop your own scanner, whatever it is)
#
# root is required: the raw HCI socket and hciconfig need it.
#
# exit code: 0 when at least one adapter qualified for at least one job, 1 otherwise.
#
#  MIT license   Karl Wachs August 2026
#
####################
#!/usr/bin/env python3
# -*- coding: utf-8 -*-import sys
import os
import re
import time
import json
import struct
import socket as pySocket
import subprocess

VERSION = 2.4

OGF_LE_CTL                 = 0x08
OCF_LE_SET_EVENT_MASK      = 0x0001
OCF_LE_READ_LOCAL_FEATURES = 0x0003
OCF_LE_SET_ADV_PARAMETERS  = 0x0006
OCF_LE_SET_ADV_DATA        = 0x0008
OCF_LE_SET_ADV_ENABLE      = 0x000A
OCF_LE_SET_SCAN_PARAMETERS = 0x000B
OCF_LE_SET_SCAN_ENABLE     = 0x000C
OCF_LE_EXT_SCAN_PARAMS     = 0x0041
OCF_LE_EXT_SCAN_ENABLE     = 0x0042

SOL_HCI       = 0
HCI_FILTER    = 2
HCI_EVENT_PKT = 0x04

# thresholds, all from measurements on real hardware (2026-07):
#   109/s pi onboard broadcom | 92/s good USB | 56/s CSR clone | 24/s ASUS extended | 2.6/s Barrot
SCAN_GOOD      = 80.0		# reports/s: full-time scanner
SCAN_OK        = 40.0		# reports/s: usable scanner (clone territory)
EXT_MIN_RATE   = 1.0		# reports/s in extended mode to call extended reception WORKING
EXT_MIN_MACS   = 5			# ... and it has to hear more than one talkative neighbour
CLONE_ACL_MTU  = 400		# ACL MTU <= this = CSR8510 clone: scans fine, unreliable for connects
# COMBINED scan+BLE5-listener on ONE radio is judged differently from a dedicated scanner. A beacon
# tracker typically listens for ~60 s and then summarises (keeping an AVERAGE rssi per device), so
# what matters is not reports/s but "did I hear each device often enough in one summary window".
# A radio that delivers half the raw rate but still sees every device ~25x per minute is perfectly
# good here.
# RELATIVE to what the same radio hears in BLE4 mode - the absolute mac count is a property of the
# neighbourhood (60 in a block of flats, 4 in a quiet house), not of the dongle.
COMBINED_MIN_COVERAGE  = 60.0	# % of the macs this radio hears in BLE4-only mode
COMBINED_MIN_PER_MIN   = 5.0	# reports per mac per 60 s window - enough to average an rssi


def shell(cmd):
	try:
		out = subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT)
		if not isinstance(out, str): out = out.decode("utf-8", "replace")
		return out
	except Exception as e:
		return "{}".format(e)


def listAdapters():
	return re.findall(r"^(hci\d+):", shell("hciconfig"), re.M)


def fingerprint(hci):
	"""everything that identifies the MODEL, so a result can be recognised again later"""
	out = shell("hciconfig -a {}".format(hci))
	fp  = {"hci": hci, "mac": "", "manufacturer": "", "bus": "", "aclMTU": 0, "scoMTU": "",
			"usb": "", "usbName": "", "usbProven": False, "kernel": shell("uname -r").strip()}
	m = re.search(r"BD Address: ([0-9A-F:]{17})", out)
	if m:	fp["mac"] = m.group(1)
	m = re.search(r"Manufacturer: (.*)", out)
	if m:	fp["manufacturer"] = m.group(1).strip()
	m = re.search(r"Bus: (\w+)", out)
	if m:	fp["bus"] = m.group(1)
	m = re.search(r"ACL MTU: (\d+):(\d+)\s+SCO MTU: (\d+):(\d+)", out)
	if m:
		fp["aclMTU"] = int(m.group(1))
		fp["scoMTU"] = "{}:{}".format(m.group(3), m.group(4))
	fp["oui"] = fp["mac"][:8]
	# USB id: the only stable model identifier - two dongles of the same model share it, and it is
	# what you type into a shop search. UART (onboard) radios simply have none.
	if fp["bus"].upper() == "USB":
		# walk sysfs for THIS adapter: /sys/class/bluetooth/hciN/device/.. up to the usb device that
		# carries idVendor/idProduct. Reading lsusb instead reported the FIRST dongle for every
		# adapter - a live report showed a Realtek and a Barrot both as "33fa:0012 UGREEN".
		try:
			base = os.path.realpath("/sys/class/bluetooth/{}/device".format(hci))
			for _ in range(6):
				vid = os.path.join(base, "idVendor")
				pid = os.path.join(base, "idProduct")
				if os.path.isfile(vid) and os.path.isfile(pid):
					f = open(vid); v = f.read().strip(); f.close()
					f = open(pid); d = f.read().strip(); f.close()
					fp["usb"]       = "{}:{}".format(v, d)
					fp["usbProven"] = True				# read from THIS adapter's sysfs path
					for nn in ["manufacturer", "product"]:
						pp = os.path.join(base, nn)
						if os.path.isfile(pp):
							f = open(pp)
							fp["usbName"] = (fp["usbName"] + " " + f.read().strip()).strip()
							f.close()
					break
				base = os.path.dirname(base)
		except Exception:
			pass
		if fp["usb"] == "":			# sysfs layout not as expected: fall back to lsusb (first match)
			for line in shell("lsusb").split("\n"):
				mm = re.search(r"ID ([0-9a-f]{4}:[0-9a-f]{4})\s*(.*)", line)
				if not mm:	continue
				if mm.group(1) in ("1d6b:0002", "1d6b:0003", "1d6b:0001"):	continue	# root hubs
				# the id goes in CLEAN - it is the catalogue KEY. Appending "?" here filed the same
				# dongle under "0b05:190e" on a run where sysfs worked and "0b05:190e?" on a run where
				# it did not, silently splitting one model into two entries. The uncertainty is a
				# separate flag now, and only the PRINTED form carries the "?" (see usbText).
				fp["usb"]       = mm.group(1)
				fp["usbProven"] = False					# first lsusb match, not proven to be THIS adapter
				fp["usbName"]   = mm.group(2).strip()
				break
	return fp


def usbText(fp):
	"""usb id for DISPLAY: a trailing "?" means it came from the lsusb fallback and is not proven to
	belong to this adapter. Never use this for the catalogue key - use fp["usb"], which is clean."""
	if not fp.get("usb", ""):	return ""
	return fp["usb"] + ("" if fp.get("usbProven", False) else "?")


def vendorName(fp):

	name = "{}".format(fp.get("usbName", "") or "").strip()
	if name == "":
		name = re.sub(r"\s*\(\d+\)\s*$", "", "{}".format(fp.get("manufacturer", "") or "").strip())
	return name or "unknown"


def resetHCI(hci):
	shell("sudo hciconfig {} reset".format(hci))
	time.sleep(0.4)
	shell("sudo hciconfig {} up".format(hci))
	time.sleep(0.4)
	return "UP" in shell("hciconfig {}".format(hci))


def resetAllAdapters(adapters):
	"""Reset EVERY adapter before the run, not just the one under test.

	Adapters are not independent: a sibling USB radio left in extended-scan mode (or with a stuck
	scan state from another program) skews the neighbour's numbers, and a reset on one USB adapter
	has been seen to ripple to another on the same bus. Starting from a known state for all of them
	is the only way two runs are comparable.

	Inputs:
	    adapters (list): hci names
	Outputs:
	    None
	"""
	print("resetting all adapters first: {}".format(", ".join(adapters)))
	for hci in adapters:
		shell("sudo hciconfig {} down".format(hci))
	time.sleep(0.3)
	for hci in adapters:
		shell("sudo hciconfig {} reset".format(hci))
	time.sleep(0.5)
	for hci in adapters:
		shell("sudo hciconfig {} up".format(hci))
	time.sleep(0.5)
	for hci in adapters:
		state = "UP" if "UP" in shell("hciconfig {}".format(hci)) else "DOWN"
		mtu   = fingerprint(hci)["aclMTU"]
		print("   {}: {}  ACL MTU:{}{}".format(hci, state, mtu, "   <-- not usable" if (state != "UP" or mtu == 0) else ""))


def openSock(hci):
	devId = int(hci.replace("hci", ""))
	sock  = pySocket.socket(pySocket.AF_BLUETOOTH, pySocket.SOCK_RAW, pySocket.BTPROTO_HCI)
	sock.bind((devId,))
	# struct hci_filter (16 bytes; kernels >= 6.1.91 reject shorter): all HCI events
	flt = struct.pack("<IIIH2x", 1 << HCI_EVENT_PKT, 0xFFFFFFFF, 0xFFFFFFFF, 0)
	sock.setsockopt(SOL_HCI, HCI_FILTER, flt)
	sock.settimeout(0.5)
	return sock


def cmdComplete(sock, ocf, params=b""):
	"""one LE command + its command-complete; (status, event). status -1 = no answer"""
	opcode = (OGF_LE_CTL << 10) | ocf
	sock.send(b"\x01" + struct.pack("<HB", opcode, len(params)) + params)
	t0 = time.time()
	while time.time() - t0 < 1.2:
		try:	ev = bytearray(sock.recv(255))
		except Exception:	break
		if len(ev) >= 7 and ev[1] == 0x0E and (ev[4] | (ev[5] << 8)) == opcode:
			return ev[6], ev
	return -1, bytearray()


def countReports(sock, secs, subevent):
	"""Delivery measurement AND performance profile.

	Inputs:
	    sock: open raw HCI socket, scanning already enabled
	    secs (float): measuring window
	    subevent (int): 0x02 legacy adv report, 0x0D extended
	Outputs:
	    dict: n, macs, buckets(list per second), gapMax, rssiMean, rssiMin, rssiMax
	"""
	n, macs   = 0, set()
	nLegacyPdu, nExtPdu = [0], [0]			# only filled for subevent 0x0D - see the split below
	macsLegacyPdu, macsExtPdu = set(), set()
	rssis     = []
	buckets   = [0] * int(max(1, secs))
	t0        = time.time()
	tLast     = t0
	gapMax    = 0.0
	while True:
		now = time.time()
		if now - t0 >= secs:	break
		try:	ev = bytearray(sock.recv(512))
		except Exception:
			if time.time() - tLast > gapMax:	gapMax = time.time() - tLast
			continue
		if len(ev) > 4 and ev[1] == 0x3E and ev[3] == subevent:
			n += 1
			idx = int(time.time() - t0)
			if 0 <= idx < len(buckets):	buckets[idx] += 1
			gap   = time.time() - tLast
			if gap > gapMax:	gapMax = gap
			tLast = time.time()
			try:
				# LEGACY 0x02 report:   [4]=num, [5]=evt, [6]=addrType, [7:13]=mac ... rssi = LAST byte
				# EXTENDED 0x0D report: [4]=num, [5:7]=evt, [7]=addrType, [8:14]=mac,
				#                       [14]=primaryPhy [15]=secondaryPhy [16]=sid [17]=txPower [18]=RSSI
				# The extended layout is NOT "rssi at the end" (data follows), and the mac starts one
				# byte earlier than the legacy one - both were wrong here: the report showed
				# "rssi mean/max 16/113 dBm", which are txPower/length bytes read as a signed rssi.
				if subevent == 0x02:
					macs.add(bytes(ev[7:13]))
					r = ev[-1]
				else:
					macs.add(bytes(ev[8:14]))
					r = ev[18] if len(ev) > 18 else 0
					# Event_Type bit 4 = "legacy PDU used": a BT5 controller in EXTENDED scan mode
					# reports the ordinary BLE4 advertisements too, flagged with this bit. Counting
					# the two apart is the whole question of "can ONE dongle cover BLE4 + BLE5".
					evt = ev[5] | (ev[6] << 8)
					if evt & 0x0010:
						nLegacyPdu[0] += 1
						macsLegacyPdu.add(bytes(ev[8:14]))
					else:
						nExtPdu[0] += 1
						macsExtPdu.add(bytes(ev[8:14]))
				rssis.append(r - 256 if r > 127 else r)
			except Exception:	pass
	if time.time() - tLast > gapMax:	gapMax = time.time() - tLast
	out = {"n": n, "macs": len(macs), "buckets": buckets, "gapMax": round(gapMax, 1),
			"rssiMean": 0, "rssiMin": 0, "rssiMax": 0,
			"nLegacyPdu": nLegacyPdu[0], "macsLegacyPdu": len(macsLegacyPdu),
			"nExtPdu": nExtPdu[0], "macsExtPdu": len(macsExtPdu)}
	if rssis:
		out["rssiMean"] = int(sum(rssis) / float(len(rssis)))
		out["rssiMin"]  = min(rssis)
		out["rssiMax"]  = max(rssis)
	return out


def phaseExtended(hci, secs, res):
	resetHCI(hci)
	sock = openSock(hci)
	try:
		# order matters: the event mask FIRST. bit12 (extended adv report) is OFF in the controller
		# default, so an adapter that is otherwise fine scans happily and delivers NOTHING.
		stM, _ = cmdComplete(sock, OCF_LE_SET_EVENT_MASK, struct.pack("<Q", 0x000FFFFF))
		cmdComplete(sock, OCF_LE_EXT_SCAN_ENABLE, struct.pack("<BBHH", 0x00, 0x00, 0, 0))	# clear a stuck scan
		stP, _ = cmdComplete(sock, OCF_LE_EXT_SCAN_PARAMS, struct.pack("<BBBBHH", 0x00, 0x00, 0x01, 0x01, 0x0010, 0x0010))
		stE, _ = cmdComplete(sock, OCF_LE_EXT_SCAN_ENABLE, struct.pack("<BBHH", 0x01, 0x00, 0, 0))
		res["extCmdStatus"] = [stM, stP, stE]
		if stP != 0 or stE != 0:
			res["nExt"], res["uExt"] = 0, 0
			return
		prof = countReports(sock, secs, 0x0D)
		res["nExt"], res["uExt"], res["profExt"] = prof["n"], prof["macs"], prof
		# what the SAME extended scan heard, split by PDU kind: this is what a single combined
		# BLE4+BLE5 listener would actually deliver.
		res["nExtLegacyPdu"], res["uExtLegacyPdu"] = prof["nLegacyPdu"], prof["macsLegacyPdu"]
		res["nExtOnlyPdu"],   res["uExtOnlyPdu"]   = prof["nExtPdu"],    prof["macsExtPdu"]
		cmdComplete(sock, OCF_LE_EXT_SCAN_ENABLE, struct.pack("<BBHH", 0x00, 0x00, 0, 0))
	finally:
		try:	sock.close()
		except Exception:	pass


def phaseLegacy(hci, secs, res):
	resetHCI(hci)
	sock = openSock(hci)
	try:
		stP, _ = cmdComplete(sock, OCF_LE_SET_SCAN_PARAMETERS, struct.pack("<BHHBB", 0x01, 0x0010, 0x0010, 0x00, 0x00))
		stE, _ = cmdComplete(sock, OCF_LE_SET_SCAN_ENABLE, struct.pack("<BB", 0x01, 0x00))
		res["legCmdStatus"] = [stP, stE]
		if stP != 0 or stE != 0:
			# 0x0C = Command Disallowed: the controller is locked to the extended command family.
			# That is the Barrot signature and it means "never give this dongle the scan job".
			res["nLeg"], res["uLeg"] = 0, 0
			return
		prof = countReports(sock, secs, 0x02)
		res["nLeg"], res["uLeg"], res["profLeg"] = prof["n"], prof["macs"], prof
		cmdComplete(sock, OCF_LE_SET_SCAN_ENABLE, struct.pack("<BB", 0x00, 0x00))
	finally:
		try:	sock.close()
		except Exception:	pass


def phaseAdvertise(hci, res):
	"""can it BROADCAST? an iBeacon/Eddystone transmitter needs legacy advertising to work."""
	resetHCI(hci)
	sock = openSock(hci)
	try:
		stP, _ = cmdComplete(sock, OCF_LE_SET_ADV_PARAMETERS,
							struct.pack("<HHBBB6sBB", 0x00A0, 0x00A0, 0x03, 0x00, 0x00, b"\x00"*6, 0x07, 0x00))
		data   = bytearray(32)
		payload = bytes(bytearray([0x02, 0x01, 0x06, 0x03, 0x03, 0xAA, 0xFE]))
		data[0] = len(payload)
		data[1:1+len(payload)] = payload
		stD, _ = cmdComplete(sock, OCF_LE_SET_ADV_DATA, bytes(data))
		stE, _ = cmdComplete(sock, OCF_LE_SET_ADV_ENABLE, struct.pack("<B", 0x01))
		cmdComplete(sock, OCF_LE_SET_ADV_ENABLE, struct.pack("<B", 0x00))
		res["advCmdStatus"] = [stP, stD, stE]
	finally:
		try:	sock.close()
		except Exception:	pass


def phaseConnectRepeat(hci, mac, res, tries, timeout=12.):
	"""CONNECT PERFORMANCE: one successful connect proves nothing - the ENOSYS problem on onboard
	radios shows up as "works, but only after 3 attempts and 30 s". So connect N times and report
	the success RATE, the times, and which errors came back.

	Inputs:
	    hci (str), mac (str): adapter and target tag
	    res (dict): result dict to fill
	    tries (int): how many attempts
	    timeout (float): per attempt
	Outputs:
	    None
	"""
	ok, times, errs = 0, [], {}
	for ii in range(max(1, tries)):
		one = {"fingerprint": res["fingerprint"]}
		phaseConnect(hci, mac, one, timeout)
		if one.get("connectOk"):
			ok += 1
			times.append(one.get("connectSecs", 0))
		else:
			ee = one.get("connectErr", "?")
			errs[ee] = errs.get(ee, 0) + 1
		time.sleep(1.0)
	res["connectTarget"]   = mac
	res["connectAddrType"] = "random" if peerAddrType(mac) == BDADDR_LE_RANDOM else "public"
	res["connectTries"]    = max(1, tries)
	res["connectOk"]      = ok > 0
	res["connectRate"]    = round(100.0 * ok / float(max(1, tries)), 0)
	res["connectSecs"]    = round(sum(times) / float(len(times)), 1) if times else 0
	res["connectSecsMax"] = round(max(times), 1) if times else 0
	res["connectErrors"]  = errs


#  kernel l2cap bdaddr types. 0x00 is BDADDR_BREDR, NOT "LE public": using it, or using 0x01 where
#  0x02 belongs, makes the controller look for a device that is not there and EVERY attempt on
#  EVERY radio ends in "timeout".
BDADDR_LE_PUBLIC = 0x01
BDADDR_LE_RANDOM = 0x02

forceAddrType = ""		# "public" / "random" from the command line, "" = decide per address


def peerAddrType(mac):
	if forceAddrType == "public":	return BDADDR_LE_PUBLIC
	if forceAddrType == "random":	return BDADDR_LE_RANDOM
	try:	first = int(mac.split(":")[0], 16)
	except Exception:	return BDADDR_LE_RANDOM
	if (first & 0xC0) == 0xC0:	return BDADDR_LE_RANDOM		# static random - the beacon tag case
	if first & 0x02:			return BDADDR_LE_RANDOM		# locally administered = not an IEEE public address
	return BDADDR_LE_PUBLIC


def phaseConnect(hci, mac, res, timeout=12.):
	"""OPTIONAL: a real ATT connection to a real tag - the only honest connect test.
	Needs a beacon that is connectable RIGHT NOW, so it can never be part of the automatic run."""
	res["connectTarget"] = mac
	try:
		import ctypes
		BTPROTO_L2CAP = 0
		libc = ctypes.CDLL("libc.so.6", use_errno=True)

		def sockaddr(macStr, addrType):
			# struct sockaddr_l2 {u16 family; u16 psm; bdaddr_t addr; u16 cid; u8 addr_type;}
			# A "<HH6sBB" layout puts the ATT CID in the PSM field and a byte where the u16 cid
			# belongs, so the kernel gets a nonsense address: connect() returns instantly and every
			# adapter "passes" with 100% in 0.0s.
			bb = bytes(bytearray(int(x, 16) for x in reversed(macStr.split(":"))))
			return struct.pack("<HH6sHB", 31, 0, bb, 4, addrType) + b"\x00"

		ownMac = res["fingerprint"]["mac"]
		s  = pySocket.socket(31, pySocket.SOCK_SEQPACKET, BTPROTO_L2CAP)
		sa = sockaddr(ownMac, BDADDR_LE_PUBLIC)		# the local adapter is always a public address
		libc.bind(s.fileno(), sa, len(sa))
		s.setblocking(False)
		t0 = time.time()
		sa = sockaddr(mac, peerAddrType(mac))		# public or RANDOM - see peerAddrType()
		ret = libc.connect(s.fileno(), sa, len(sa))
		import select
		if ret != 0:
			rl, wl, xl = select.select([], [s], [], timeout)
			if not wl:
				res["connectOk"], res["connectSecs"], res["connectErr"] = False, time.time()-t0, "timeout"
				s.close()
				return
			err = s.getsockopt(pySocket.SOL_SOCKET, pySocket.SO_ERROR)
			if err != 0:
				res["connectOk"], res["connectSecs"], res["connectErr"] = False, time.time()-t0, "SO_ERROR:{}".format(err)
				s.close()
				return
		res["connectOk"], res["connectSecs"], res["connectErr"] = True, time.time()-t0, ""
		s.close()
	except Exception as e:
		res["connectOk"], res["connectErr"] = False, "{}".format(e)


def verdict(res, secs):
	"""which JOBS this adapter qualifies for - the actual output of the program"""
	fp     = res["fingerprint"]
	rLeg   = res.get("nLeg", 0) / float(secs) if res.get("nLeg", 0) > 0 else 0.0
	rExt   = res.get("nExt", 0) / float(secs) if res.get("nExt", 0) > 0 else 0.0
	roles, why = [], []

	if rExt >= EXT_MIN_RATE and res.get("uExt", 0) >= EXT_MIN_MACS:
		roles.append("BLE5-listener")
	else:
		why.append("no BLE5: {:.1f} BLE5 reports/s from {} macs".format(rExt, res.get("uExt", 0)))

	if rLeg >= SCAN_OK:
		roles.append("scan-BLE4" + ("" if rLeg >= SCAN_GOOD else "(weak)"))
	else:
		if res.get("legCmdStatus", [0, 0])[0] == 0x0C or res.get("legCmdStatus", [0, 0])[1] == 0x0C:
			why.append("BLE4 scan commands REJECTED (0x0C) - BLE5-only firmware, cannot be the scan radio")
		else:
			why.append("scan too slow: {:.1f}/s (need {:.0f})".format(rLeg, SCAN_OK))

	# ONE radio for BLE4+BLE5. A BT5 controller is REQUIRED to report legacy advs while in extended
	# mode (event type bit 4), but how much it delivers is firmware - so it is measured, not assumed.
	# Judged by COVERAGE + samples per summary window, NOT by reports/s: see COMBINED_MIN_* above.
	rLegInExt = res.get("nExtLegacyPdu", 0) / float(secs)
	uLegInExt = res.get("uExtLegacyPdu", 0)
	perMacMin = (60.0 * rLegInExt / uLegInExt) if uLegInExt > 0 else 0.0
	res["combinedPerMacPerMin"] = round(perMacMin, 1)
	uLegOwn  = res.get("uLeg", 0)
	coverage = (100.0 * uLegInExt / uLegOwn) if uLegOwn > 0 else 100.0
	res["combinedCoveragePct"] = round(coverage, 0)
	if coverage >= COMBINED_MIN_COVERAGE and perMacMin >= COMBINED_MIN_PER_MIN and rExt >= EXT_MIN_RATE:
		roles.append("scan-BLE4+BLE5")
		why.append("one radio can do BLE4+BLE5 here: in BLE5 mode it still reaches {:.0f}% of the macs it hears in BLE4 mode ({} of {}), {:.0f} reports per mac per minute".format(
					coverage, uLegInExt, uLegOwn, perMacMin))
	elif rExt >= EXT_MIN_RATE:
		if coverage < COMBINED_MIN_COVERAGE:
			why.append("not a combined BLE4+BLE5 scanner: in BLE5 mode it reaches only {:.0f}% of the macs it hears in BLE4 mode ({} of {})".format(
						coverage, uLegInExt, uLegOwn))
		else:
			why.append("not a combined BLE4+BLE5 scanner: only {:.0f} reports per mac per minute in BLE5 mode (need {:.0f})".format(
						perMacMin, COMBINED_MIN_PER_MIN))

	if res.get("advCmdStatus", [1, 1, 1]) == [0, 0, 0]:	roles.append("broadcast")
	else:												why.append("cannot advertise (adv cmd status {})".format(res.get("advCmdStatus")))

	# CONNECT: the ACL MTU guess and the MEASUREMENT reconciled in ONE place. A measurement always
	# beats the guess - a radio that connected 3/3 IS a connect radio whatever its MTU says (the
	# Barrot does 100% with ACL MTU 679, and used to be reported as "connect-PROVEN" WITHOUT
	# "connect", which reads as a contradiction), and one that failed every attempt is not, however
	# full sized its MTU. Only without a connect test does the MTU decide on its own. Every MTU band
	# produces a note, so a role missing because of the MTU is never silent - 401..1020 used to
	# match neither branch and vanished without a word.
	acl  = fp.get("aclMTU", 0)
	rate = res.get("connectRate", -1)					# -1 = no connect test was run at all
	if rate >= 100:
		roles.append("connect")
		roles.append("connect-PROVEN")
	elif rate >= 1:
		roles.append("connect(weak)")
		why.append("connect unreliable: only {:.0f}% of {} attempts succeeded {}".format(
					rate, res.get("connectTries"), res.get("connectErrors") or ""))
	elif rate == 0:
		# name the address type here too: "timeout on every radio" is what a WRONG address type looks
		# like, and it is the first thing to re-check (addrType=public|random) before blaming a dongle.
		why.append("connect FAILED in all {} attempts {} as address type {} - no connect role, whatever the ACL MTU ({})".format(
					res.get("connectTries"), res.get("connectErrors") or "", res.get("connectAddrType", "?"), acl))
	elif acl >= 1021:									roles.append("connect")
	elif 0 < acl <= CLONE_ACL_MTU:						why.append("clone dongle (ACL MTU {}) - connects unreliable".format(acl))
	elif acl > 0:										why.append("ACL MTU {} is below the 1021 of a full controller - connect not assumed, run with connect=<MAC> to settle it".format(acl))

	# stability: an average hides a radio that delivers a burst and then goes silent. A gap of more
	# than a quarter of the window with nothing at all means the job would keep dropping out.
	for label, key in [["BLE4", "profLeg"], ["BLE5", "profExt"]]:
		pr = res.get(key)
		if not pr or pr.get("n", 0) == 0:	continue
		if pr.get("gapMax", 0) > max(3.0, secs / 4.0):
			why.append("{} delivery UNSTABLE: {:.1f}s with no report at all".format(label, pr.get("gapMax")))
		if pr.get("rssiMean", 0) and pr.get("rssiMean", 0) < -90:
			why.append("{} sensitivity poor: mean rssi {} dBm - hears only the loudest neighbours".format(label, pr.get("rssiMean")))

	res["rateLegacy"], res["rateExtended"] = round(rLeg, 1), round(rExt, 1)
	res["roles"], res["notes"] = roles, why
	return roles, why


def report(res, secs):
	fp = res["fingerprint"]
	print("")
	print("==== {}  {}  ({}) ====".format(fp["hci"], fp["mac"], fp["manufacturer"] or "?"))
	print("  bus:{}  ACL MTU:{}  SCO MTU:{}  usb:{} {}".format(
			fp["bus"], fp["aclMTU"], fp["scoMTU"], usbText(fp) or "-", fp["usbName"]))
	print("  kernel:{}   LE feature bit12 (CLAIMED BLE5): {}".format(fp["kernel"], res.get("claimsBLE5")))
	if not res.get("healthy", True):
		print("  HEALTH   : adapter does not come up properly (ACL MTU {}) - unusable".format(fp["aclMTU"]))
	print("  BLE4     : {:5d} reports = {:6.1f}/s from {:3d} macs   cmd status:{}".format(
			res.get("nLeg", 0), res.get("rateLegacy", 0), res.get("uLeg", 0), res.get("legCmdStatus")))
	print("  BLE5     : {:5d} reports = {:6.1f}/s from {:3d} macs   cmd status:{}".format(
			res.get("nExt", 0), res.get("rateExtended", 0), res.get("uExt", 0), res.get("extCmdStatus")))
	for label, key in [["BLE4", "profLeg"], ["BLE5", "profExt"]]:
		pr = res.get(key)
		if not pr or pr.get("n", 0) == 0:	continue
		bk = pr.get("buckets", [])
		print("  {:9s} performance: rssi mean/max {}/{} dBm, longest silence {:.1f}s, per-second {}".format(
				label, pr.get("rssiMean"), pr.get("rssiMax"), pr.get("gapMax"),
				"/".join("{}".format(b) for b in bk[:12]) + ("..." if len(bk) > 12 else "")))
	if "nExtLegacyPdu" in res:
		# the SAME extended scan, split by PDU kind. "legacyPDU" is what a BLE4-only tag looks like
		# when a BT5 controller reports it in extended mode - if that number is close to the LEGACY
		# phase above, this one radio can do BLE4 and BLE5 together.
		rl = res.get("nExtLegacyPdu", 0) / float(secs)
		re_ = res.get("nExtOnlyPdu", 0) / float(secs)
		leg = res.get("nLeg", 0) / float(secs)
		keep = (100.0 * rl / leg) if leg > 0 else 0.0
		print("  COMBINED : BLE5 scan reports {:.1f}/s BLE4-advs from {} macs + {:.1f}/s BLE5-advs from {} macs"
				.format(rl, res.get("uExtLegacyPdu", 0), re_, res.get("uExtOnlyPdu", 0)))
		if res.get("legCmdStatus", [0, 0])[0] == 0x0C or res.get("legCmdStatus", [0, 0])[1] == 0x0C:
			# extended-only firmware: there is no legacy-mode number to compare against, and printing
			# "keeps 0% of the 0.0/s" reads like a failure when it is simply not applicable.
			print("             -> BLE5-only firmware: no BLE4 scan mode to compare with;"
					" the BLE4 advs above are all it can hear")
		elif leg > 0:
			print("             -> keeps {:.0f}% of the {:.1f}/s this radio sees in BLE4-only mode".format(keep, leg))
		if res.get("uExtLegacyPdu", 0) > 0:
			print("             -> {:.0f} reports per mac per minute - a tracker that summarises every ~60 s keeps an AVERAGE rssi"
					.format(60.0 * rl / res["uExtLegacyPdu"]))
	print("  ADVERTISE: {}".format("ok" if res.get("advCmdStatus") == [0, 0, 0] else "FAILED {}".format(res.get("advCmdStatus"))))
	if "connectRate" in res:
		# the ADDRESS TYPE belongs on this line: connecting to a static-random tag as "public" makes
		# every attempt time out on every radio and looks exactly like broken hardware (it did).
		print("  CONNECT  : {:.0f}% of {} attempts to {} ({}), mean {:.1f}s max {:.1f}s  {}".format(
				res.get("connectRate", 0), res.get("connectTries"), res.get("connectTarget"),
				res.get("connectAddrType", "?"),
				res.get("connectSecs", 0), res.get("connectSecsMax", 0),
				"errors:{}".format(res.get("connectErrors")) if res.get("connectErrors") else ""))
	print("  ROLES    : {}".format(", ".join(res["roles"]) if res["roles"] else "NONE - do not use this dongle"))
	for w in res["notes"]:
		print("             - {}".format(w))


def structured(entries):
	"""the machine readable result: one entry per adapter, no measurement raw data"""
	return [{"fingerprint": r["fingerprint"], "roles": r.get("roles"),
			"rateLegacy": r.get("rateLegacy"), "rateExtended": r.get("rateExtended"),
			"notes": r.get("notes")} for r in entries]


def writeStructured(entries, path):
	"""json=<path>: write the structured result for whatever wants to read it"""
	if "{}".format(path).strip() == "":	return
	try:
		f = open(path, "w")
		f.write(json.dumps(structured(entries), indent=2))
		f.close()
		print("\nstructured result written: {}".format(path))
	except Exception as e:
		print("\ncould not write the structured result {}: {}".format(path, e))


def saveCatalogue(entries, path):
	"""append to the catalogue, keyed by usb id (or OUI for onboard radios), so results from several
	runs and several hosts accumulate in one file and the same dongle model can be looked up again.
	catalogue=none skips it."""
	if "{}".format(path).strip().lower() in ("none", "off", ""):
		print("\nno catalogue written (catalogue=none)")
		return
	cat = {}
	try:
		if os.path.isfile(path):
			f = open(path)
			cat = json.load(f)
			f.close()
	except Exception:
		cat = {}
	for res in entries:
		fp  = res["fingerprint"]
		key = fp["usb"] or fp["oui"] or fp["mac"]
		cat.setdefault(key, [])
		cat[key].append({"ts": time.strftime("%Y-%m-%d %H:%M:%S"), "host": shell("hostname").strip(),
						"mac": fp["mac"], "manufacturer": fp["manufacturer"], "usbName": fp["usbName"],
						"bus": fp["bus"], "aclMTU": fp["aclMTU"], "scoMTU": fp["scoMTU"], "kernel": fp["kernel"],
						"claimsBLE5": res.get("claimsBLE5"), "rateLegacy": res.get("rateLegacy"),
						"rateExtended": res.get("rateExtended"), "roles": res.get("roles"), "notes": res.get("notes")})
	try:
		f = open(path, "w")
		f.write(json.dumps(cat, indent=2, sort_keys=True))
		f.close()
		try:	os.chmod(path, 0o666)
		except Exception:	pass
		print("\ncatalogue updated: {} ({} model(s) known)".format(path, len(cat)))
	except Exception as e:
		print("\ncould not write catalogue {}: {}".format(path, e))


def main():
	global forceAddrType
	secs      = 10
	adapters  = []
	connectTo    = ""
	connectTries = 3
	catalogue = "dongleCatalogue.json"
	jsonOut   = ""
	for a in sys.argv[1:]:
		if a.startswith("connect="):		connectTo = a.split("=", 1)[1].strip().upper()
		elif a.startswith("tries="):		connectTries = int(a.split("=", 1)[1].strip())
		elif a.startswith("catalogue="):	catalogue = a.split("=", 1)[1].strip()
		elif a.startswith("json="):			jsonOut   = a.split("=", 1)[1].strip()
		elif a.startswith("addrType="):		forceAddrType = a.split("=", 1)[1].strip().lower()
		elif a.startswith("hci"):			adapters.append(a)
		elif a in ("-h", "--help", "help"):
			print(__doc__ or "see the comment block at the top of this file")
			return 0
		else:
			try:	secs = int(a)
			except Exception:	pass
	if not adapters:	adapters = listAdapters()
	if not adapters:
		print("no bluetooth adapters found")
		return 1

	print("BLE dongle role qualification v{}   {} s per measuring phase".format(VERSION, secs))
	if os.geteuid() != 0:
		print("WARNING: not running as root - the raw HCI socket and hciconfig will fail. Use sudo.")
	print("REMINDER: no other program may be using the radios (stop your scanner, and usually"
			" 'sudo systemctl stop bluetooth')")
	if connectTo:	print("connect test against {} ({} attempts, address type {})".format(connectTo, connectTries,
						"random" if peerAddrType(connectTo) == BDADDR_LE_RANDOM else "public"))
	resetAllAdapters(listAdapters())		# ALL of them, not only the ones we are about to test

	entries = []
	for hci in adapters:
		res = {"fingerprint": fingerprint(hci)}
		res["healthy"] = resetHCI(hci) and res["fingerprint"]["aclMTU"] > 0
		if res["healthy"]:
			sock = openSock(hci)
			st, ev = cmdComplete(sock, OCF_LE_READ_LOCAL_FEATURES)
			res["claimsBLE5"] = bool(st == 0 and len(ev) >= 15 and (ev[8] & 0x10))
			sock.close()
			phaseExtended(hci, secs, res)
			phaseLegacy(hci, secs, res)
			phaseAdvertise(hci, res)
			if connectTo:	phaseConnectRepeat(hci, connectTo, res, connectTries)
		else:
			res["claimsBLE5"] = False
		verdict(res, secs)
		report(res, secs)
		entries.append(res)

	print("\n==== summary ====")
	for res in entries:
		fp = res["fingerprint"]
		# usb id ONLY - no OUI fallback: the OUI is the first 3 bytes of the mac one column to the
		# left, so "B8:27:EB  B8:27:EB:9D:3A:63" said the same thing twice. UART radios have no usb
		# id and simply leave the column empty; the vendor name identifies them.
		print("{:6s} {:18s} {:5s} {:11s} {:26s} -> {}".format(fp["hci"], fp["mac"], fp.get("bus", ""),
				usbText(fp), vendorName(fp),
				", ".join(res["roles"]) if res["roles"] else "NONE"))
	saveCatalogue(entries, catalogue)
	writeStructured(entries, jsonOut)
	return 0 if any(r.get("roles") for r in entries) else 1


sys.exit(main())

sudo python3 checkBLEdongles.py 
BLE dongle role qualification v2.4   10 s per measuring phase
REMINDER: no other program may be using the radios (stop your scanner, and usually 'sudo systemctl stop bluetooth')
resetting all adapters first: hci0, hci2, hci1
   hci0: UP  ACL MTU:1021
   hci2: UP  ACL MTU:1021
   hci1: UP  ACL MTU:679

==== hci0  5C:F3:70:69:69:FB  (Broadcom Corporation (15)) ====
  bus:USB  ACL MTU:1021  SCO MTU:64:1  usb:0a5c:21e8 Broadcom Corp BCM20702A0
  kernel:6.18.34+rpt-rpi-v7   LE feature bit12 (CLAIMED BLE5): False
  BLE4     :   862 reports =   86.2/s from  57 macs   cmd status:[0, 0]
  BLE5     :     0 reports =    0.0/s from   0 macs   cmd status:[0, 1, 1]
  BLE4      performance: rssi mean/max -77/-59 dBm, longest silence 0.1s, per-second 101/126/104/79/69/73/73/77/87/72
  ADVERTISE: ok
  ROLES    : scan-BLE4, broadcast, connect
             - no BLE5: 0.0 BLE5 reports/s from 0 macs

==== hci2  B8:27:EB:9D:3A:63  (Broadcom Corporation (15)) ====
  bus:UART  ACL MTU:1021  SCO MTU:64:1  usb:- 
  kernel:6.18.34+rpt-rpi-v7   LE feature bit12 (CLAIMED BLE5): False
  BLE4     :   844 reports =   84.4/s from  59 macs   cmd status:[0, 0]
  BLE5     :     0 reports =    0.0/s from   0 macs   cmd status:[0, 1, 1]
  BLE4      performance: rssi mean/max -72/-27 dBm, longest silence 0.8s, per-second 102/46/55/40/123/141/56/98/85/97
  ADVERTISE: ok
  ROLES    : scan-BLE4, broadcast, connect
             - no BLE5: 0.0 BLE5 reports/s from 0 macs

==== hci1  04:7F:0E:04:14:56  (Barrot Technology Limited (2279)) ====
  bus:USB  ACL MTU:679  SCO MTU:255:4  usb:33fa:0012 UGREEN BT6.0 Adapter
  kernel:6.18.34+rpt-rpi-v7   LE feature bit12 (CLAIMED BLE5): True
  BLE4     :     0 reports =    0.0/s from   0 macs   cmd status:[12, 12]
  BLE5     :    14 reports =    1.4/s from   8 macs   cmd status:[0, 0, 0]
  BLE5      performance: rssi mean/max -47/-8 dBm, longest silence 3.5s, per-second 0/0/2/0/0/2/3/2/5/0
  COMBINED : BLE5 scan reports 1.4/s BLE4-advs from 8 macs + 0.0/s BLE5-advs from 0 macs
             -> BLE5-only firmware: no BLE4 scan mode to compare with; the BLE4 advs above are all it can hear
             -> 10 reports per mac per minute - a tracker that summarises every ~60 s keeps an AVERAGE rssi
  ADVERTISE: ok
  ROLES    : BLE5-listener, scan-BLE4+BLE5, broadcast
             - BLE4 scan commands REJECTED (0x0C) - BLE5-only firmware, cannot be the scan radio
             - one radio can do BLE4+BLE5 here: in BLE5 mode it still reaches 100% of the macs it hears in BLE4 mode (8 of 0), 10 reports per mac per minute
             - ACL MTU 679 is below the 1021 of a full controller - connect not assumed, run with connect=<MAC> to settle it
             - BLE5 delivery UNSTABLE: 3.5s with no report at all

==== summary ====
hci0   5C:F3:70:69:69:FB  USB   0a5c:21e8   Broadcom Corp BCM20702A0   -> scan-BLE4, broadcast, connect
hci2   B8:27:EB:9D:3A:63  UART              Broadcom Corporation       -> scan-BLE4, broadcast, connect
hci1   04:7F:0E:04:14:56  USB   33fa:0012   UGREEN BT6.0 Adapter       -> BLE5-listener, scan-BLE4+BLE5, broadcast

catalogue updated: dongleCatalogue.json (5 model(s) known)

I had to split this into 3 postings # of char > 32k

Karl

1 Like