# SPDX-License-Identifier: LGPL-2.1-or-later # adapted from https://github.com/bluez/bluez/blob/master/test/example-advertisement #---------------------------------------------------------------- # HCI_Linux (uses sudo hciconfig): module for a standalone python-3.6 or later AirPlay Service-Discovery Bluetooth LE beacon for UxPlay # this requires that users can run "sudo -n hciconfig" without giving a password: # (1) (as root) create a group like "hciusers" # (2a) Linux: use visudo to create a file /etc/sudoers.d/hciusers containing a line # %hciusers ALL=(ALL) NOPASSWD: /usr/bin/hcitool, /usr/bin/hciconfig # (2b) FreeBSD: use visudo to create /usr/local/etc/sudoers.d/hciusers with the line # %hciusers ALL=(ALL) NOPASSWD: /usr/sbin/hccontrol # (3) add the users who will run uxplay-beacon.py to the group hciusers import subprocess import time import re import subprocess import platform from typing import Optional from typing import Literal #global variables hci = None advertised_port = None advertised_address = None os_name = platform.system() linux = os_name == 'Linux' freebsd = os_name == 'FreeBSD' if not linux and not freebsd: print(f'{os_name} is not supported by the HCI module') raise SystemExit(1) #help text help_text1 = ''' This HCI module requires users of the module to have elevated privileges that allow execution of a low-level Bluetooth HCI command using passwordless "sudo": (1) As System Administrator, create a group such as "hciusers") ''' if linux: help_text2 = ''' (2) use visudo to create a file /etc/sudoers.d/hciusers containing the line: %hciusers ALL=(ALL) NOPASSWD: /usr/bin/hciconfig, /usr/bin/hcitool ''' elif freebsd: disclaimer = ''' *********************************************************************** * FreeBSD: this module currently requires a patch to FreeBSD's * * hccontrol utility, that will hopefully be accepted into the FreeBSD * * source tree. It is available at the UxPlay github site Wiki: * * https://github.com/FDH2/UxPlay/wiki/hccontrol-patch-for-FreeBSD-15.0* *********************************************************************** wget https://github.com/user-attachments/files/26074904/hccontrol_FreeBSD_15_0_patch.txt ''' print(disclaimer) help_text2 = ''' (2) use visudo to create a file /usr/local/etc/sudoers.d/hciusers containing the line: %hciusers ALL=(ALL) NOPASSWD: /usr/sbin/hccontrol ''' help_text3 = ''' (3) add users of uxplay_beacon_module_HCI.py to the group "hciusers" ''' help_text = help_text1 + help_text2 + help_text3 sudo = ['sudo', '-n'] if linux: ogf = "0x08" def le_cmd(hcicmd, args): cmd = sudo + ['hcitool', '-i', hci, 'cmd', ogf, hcicmd] + args subprocess.run(cmd, capture_output=True, text=True, check=True) elif freebsd: def le_cmd(hcicmd, args): cmd = sudo + ['hccontrol', '-n', hci, hcicmd] + args subprocess.run(cmd, capture_output=True, text=True, check=True) def setup_beacon(ipv4_str: str, port: int, advmin: int, advmax: int, index: Literal[None]) -> bool: global advertised_port global advertised_address advertised_port = None advertised_address = None # setup Advertising Parameters if linux: # convert into units of 5/8 msec. advmin = (advmin * 8) // 5 advmax = (advmax * 8) // 5 min1 = f'{advmin %256 :#04x}' min2 = f'{advmin //256 :#04x}' max1 = f'{advmax % 256 :#04x}' max2 = f'{advmax // 256 :#04x}' args = [min1, min2, max1, max2, '0x03', '0x00', '0x00'] + ['0x00'] * 6 + ['0x07', '0x00'] hcicmd = "0x0006" elif freebsd: min = f'-m {advmin}' max = f'-M {advmax}' args = [min, max, 't = 3'] hcicmd = 'le_set_advertising_param' try: result = le_cmd(hcicmd, args) except subprocess.CalledProcessError as e: print(f'beacon_on error (set_advertisng_parameters):', e.stderr, e.stdout) return False # setup Advertising Data adv_head = ['0xff', '0x4c', '0x00', '0x09', '0x08', '0x13', '0x30'] adv_int = [int(hex_str, 16) for hex_str in adv_head] ip = list(map(int, ipv4_str.split('.'))) prt = [port // 256, port % 256] adv_int = adv_int + ip + prt if linux: adv_len = len(adv_int) adv_int = [adv_len + 1, adv_len ] + adv_int args = [f'{i:#04x}' for i in adv_int] args += ['0x00'] * (31 - len(adv_int)) hcicmd = '0x0008' elif freebsd: adv = ','.join(f'{byte:02x}' for byte in adv_int) args = ['-b', adv] hcicmd = 'le_set_advertising_data' try: le_cmd(hcicmd, args) except subprocess.CalledProcessError as e: print(f'beacon_on error (set_advertisng_parameters):', e.stderr, e.stdout) return False advertised_port = port advertised_address = ipv4_str return True def beacon_on() -> Optional[int]: if linux: hcicmd = '0x000a' args = ['0x01'] elif freebsd: hcicmd = 'le_set_advertising_enable' args = ['enable'] try: le_cmd(hcicmd, args) except subprocess.CalledProcessError as e: print(f'beacon_on error:', e.stderr, e.stdout) global advertised_port global advertised_address advertised_port = None advertised_address = None return None print(f'AirPlay Service-Discovery beacon transmission started') return advertised_port def beacon_off(): if linux: hcicmd = '0x000a' args = ['0x00'] elif freebsd: hcicmd = 'le_set_advertising_enable' args = ['disable'] le_cmd(hcicmd, args) print(f'AirPlay Service-Discovery beacon transmission ended') advertised_address = None advertised_port = None LMP = ["1.0b","1.1", "1.2", "2.0+EDR", "2.1+EDR", "3.0+HS"] LMP += ["4.0","4.1", "4.2", "5.0", "5.1", "5.2", "5.3", "5.4", "6.0", "6.1"] def get_bluetooth_version(device_name): if linux: cmd ='hciconfig' args = [cmd, device_name, '-a'] regexp = r"LMP Version: .*?\(0x([0-9a-fA-F])\)" elif freebsd: cmd = 'hccontrol' args = [cmd, '-n', device_name, 'Read_Local_Version_Information'] regexp = r"LMP version: .*?\[(0x[0-9a-fA-F]+)\]" try: # Run hciconfig -a for the specific device result = subprocess.check_output(args, stderr=subprocess.STDOUT, text=True) except subprocess.CalledProcessError as e: print(f"Error running {cmd} for {device_name}: {e.output}") return None except FileNotFoundError: print(f"Error: {cmd} command not found") return None # Regex to find "LMP Version: X.Y (0xZ)" lmp_version_match = re.search(regexp, result) if lmp_version_match: version_hex = lmp_version_match.group(1) return int(version_hex,16) return None def list_devices_by_version(min_version): if linux: cmd = 'hcitool' args = [cmd] args.append('dev') regexp = r"(hci\d+)" elif freebsd: cmd = 'hccontrol' args = [cmd] args.append('Read_Node_List') regexp = r"(^ubt\d+hci)" try: # Run hciconfig to list all devices devices_list_output = subprocess.check_output(args, stderr=subprocess.STDOUT, text=True) print(devices_list_output) except subprocess.CalledProcessError as e: print(f"Error running {cmd}: {e.output}") return None except FileNotFoundError: print(f"Error: {cmd} command not found") return None # Regex to find device names (e.g., hci0, hci1) device_names = re.findall(regexp, devices_list_output, re.MULTILINE) found_devices = [] for device_name in device_names: version_decimal = get_bluetooth_version(device_name) if version_decimal is None or version_decimal < min_version: continue bt_version = LMP[version_decimal] device = [device_name, bt_version] found_devices.append(device) return found_devices from typing import Optional def find_device(hci_in: Optional[str]) -> Optional[str]: global hci list = list_devices_by_version(min_version=6) if list is None or len(list) == 0: return None hci = None if hci_in is not None: for item in list: if item[0] == hci_in: hci = hci_in return hci count = 0 for index, item in enumerate(list, start = 1): count += 1 print(f'=== detected HCI device {count}. {item[0]}: Bluetooth v{item[1]}') if count == 1: hci = item[0] if count > 1: print(f'warning: {count} HCI devices were found, the first found will be used') print(f'(to override this choice, specify "--device=..." in optional arguments)') if linux: cmd = sudo + ['hciconfig', hci, 'reset'] elif freebsd: cmd = sudo + ['hccontrol', '-n', hci, 'Reset'] try: result = subprocess.run(cmd, capture_output=True, text=True, check=True) except subprocess.CalledProcessError as e: print(f'hci reset error:', e.stderr, e.stdout) print(help_text) print('cannot continue: SystemExit(1)') raise SystemExit(1) return hci print('loaded uxplay_beacon_module_HCI')