From 55e5f96ba8e69df54655cfbf107ec5773b6a607a Mon Sep 17 00:00:00 2001 From: Arca Date: Thu, 24 Jul 2025 22:10:36 +0100 Subject: [PATCH 01/10] Optionally use system_profiler to fetch the paired device list This allows the correct connection reporting for some devices with multi-point connection capability (e.g. Bose QC Ultra) --- README.md | 30 ++++++++++++ blueutil.m | 138 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 165 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index df21925..a1d399b 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,12 @@ Uses private API from IOBluetooth framework (i.e. `IOBluetoothPreference*()`). Opening Bluetooth preference pane always turns on discoverability if bluetooth power is on or if it is switched on when preference pane is open, this change of state is not reported by the function used by `blueutil`. +## Alternative Method + +By default, `blueutil` uses IOBluetooth framework APIs to query paired devices. As an alternative, you can use the `--use-system-profiler` option or set the `BLUEUTIL_USE_SYSTEM_PROFILER=1` environment variable to use the `system_profiler` command instead. + +The system_profiler method resolves an issue where some multi-point Bluetooth devices (devices that can connect to multiple devices simultaneously) may not report their connection status correctly through the IOBluetooth APIs, but do show the correct status via system_profiler. + ## Usage @@ -42,6 +48,8 @@ Without options outputs current state --format FORMAT change output format of info and all listing commands + --use-system-profiler use system_profiler instead of IOBluetooth API for paired device queries + --wait-connect ID [TIMEOUT] EXPERIMENTAL wait for device to connect --wait-disconnect ID [TIMEOUT] @@ -68,6 +76,9 @@ Favourite devices and recent access date are not stored starting with macOS 12/M Due to possible problems, blueutil will refuse to run as root user (see https://github.com/toy/blueutil/issues/41). Use environment variable BLUEUTIL_ALLOW_ROOT=1 to override (sudo BLUEUTIL_ALLOW_ROOT=1 blueutil …). +Environment variables: + BLUEUTIL_USE_SYSTEM_PROFILER=1 use system_profiler instead of IOBluetooth API (same as --use-system-profiler) + Exit codes: 0 Success 1 General failure @@ -80,6 +91,25 @@ Exit codes: ``` +### Examples + +List paired devices using IOBluetooth API (default): +```sh +blueutil --paired +``` + +List paired devices using system_profiler: +```sh +blueutil --use-system-profiler --paired +``` + +Set environment variable to always use system_profiler: +```sh +export BLUEUTIL_USE_SYSTEM_PROFILER=1 +blueutil --paired +blueutil --connected +``` + ## Install/update/uninstall ### Homebrew diff --git a/blueutil.m b/blueutil.m index f8d537c..8abfa9b 100644 --- a/blueutil.m +++ b/blueutil.m @@ -51,6 +51,34 @@ int assert_reg(int errcode, const regex_t *restrict preg, char *reason) { void _NSSetLogCStringFunction(void (*)(const char *, unsigned, BOOL)); +// Mock IOBluetoothDevice for subprocess method +@interface MockBluetoothDevice : NSObject +@property (nonatomic, strong) NSString *address; +@property (nonatomic, strong) NSString *name; +@property (nonatomic, assign) BOOL paired; +@property (nonatomic, assign) BOOL connected; +@property (nonatomic, assign) BOOL favorite; +@property (nonatomic, strong) NSDate *recentAccessDate; +@property (nonatomic, assign) int rssi; +- (NSString *)addressString; +- (BOOL)isConnected; +- (BOOL)isPaired; +- (BOOL)isFavorite; +- (BOOL)isIncoming; +- (char)RSSI; +- (char)rawRSSI; +@end + +@implementation MockBluetoothDevice +- (NSString *)addressString { return self.address; } +- (BOOL)isConnected { return self.connected; } +- (BOOL)isPaired { return self.paired; } +- (BOOL)isFavorite { return self.favorite; } +- (BOOL)isIncoming { return NO; } // Default to master mode +- (char)RSSI { return (char)self.rssi; } +- (char)rawRSSI { return (char)self.rssi; } +@end + // short names typedef int (*GetterFunc)(); typedef bool (*SetterFunc)(int); @@ -141,6 +169,8 @@ void usage(FILE *io) { "", " --format FORMAT change output format of info and all listing commands", "", + " --use-system-profiler use system_profiler for paired device queries", + "", " --wait-connect ID [TIMEOUT]", " EXPERIMENTAL wait for device to connect", " --wait-disconnect ID [TIMEOUT]", @@ -167,6 +197,9 @@ void usage(FILE *io) { "Due to possible problems, blueutil will refuse to run as root user (see https://github.com/toy/blueutil/issues/41).", "Use environment variable BLUEUTIL_ALLOW_ROOT=1 to override (sudo BLUEUTIL_ALLOW_ROOT=1 blueutil …).", "", + "Environment variables:", + " BLUEUTIL_USE_SYSTEM_PROFILER=1 use system_profiler for paired device queries (same as --use-system-profiler)", + "", "Exit codes:", }; @@ -301,6 +334,90 @@ bool parse_signed_long_arg(char *arg, long *number) { } } +NSArray *get_paired_devices_subprocess() { + NSTask *task = [[NSTask alloc] init]; + task.launchPath = @"/usr/sbin/system_profiler"; + task.arguments = @[@"SPBluetoothDataType", @"-xml"]; + + NSPipe *pipe = [NSPipe pipe]; + task.standardOutput = pipe; + task.standardError = [NSPipe pipe]; + + [task launch]; + [task waitUntilExit]; + + NSData *data = [[pipe fileHandleForReading] readDataToEndOfFile]; + if (task.terminationStatus != 0 || data.length == 0) { + return @[]; + } + + NSError *error = nil; + NSArray *plist = [NSPropertyListSerialization propertyListWithData:data + options:NSPropertyListImmutable + format:nil + error:&error]; + if (error || !plist || plist.count == 0) { + return @[]; + } + + NSMutableArray *pairedDevices = [NSMutableArray array]; + + // Navigate the system_profiler XML structure to find paired devices + for (NSDictionary *item in plist) { + NSArray *items = item[@"_items"]; + if (!items) continue; + + for (NSDictionary *bluetoothItem in items) { + // Process connected devices + NSArray *connectedDevices = bluetoothItem[@"device_connected"]; + if (connectedDevices) { + for (NSDictionary *deviceDict in connectedDevices) { + for (NSString *deviceName in deviceDict) { + NSDictionary *deviceInfo = deviceDict[deviceName]; + MockBluetoothDevice *mockDevice = [[MockBluetoothDevice alloc] init]; + mockDevice.address = deviceInfo[@"device_address"] ?: @""; + mockDevice.name = deviceName; + mockDevice.paired = YES; + mockDevice.connected = YES; + mockDevice.favorite = NO; + mockDevice.recentAccessDate = [NSDate date]; + mockDevice.rssi = deviceInfo[@"device_rssi"] ? [deviceInfo[@"device_rssi"] intValue] : -129; + + [pairedDevices addObject:mockDevice]; + } + } + } + + // Process not connected devices + NSArray *notConnectedDevices = bluetoothItem[@"device_not_connected"]; + if (notConnectedDevices) { + for (NSDictionary *deviceDict in notConnectedDevices) { + for (NSString *deviceName in deviceDict) { + NSDictionary *deviceInfo = deviceDict[deviceName]; + MockBluetoothDevice *mockDevice = [[MockBluetoothDevice alloc] init]; + mockDevice.address = deviceInfo[@"device_address"] ?: @""; + mockDevice.name = deviceName; + mockDevice.paired = YES; + mockDevice.connected = NO; + mockDevice.favorite = NO; + mockDevice.recentAccessDate = [NSDate date]; + mockDevice.rssi = -129; // Not connected, so no RSSI + + [pairedDevices addObject:mockDevice]; + } + } + } + } + } + + return [pairedDevices copy]; +} + +NSArray *get_paired_devices() { + extern bool use_subprocess_method; + return use_subprocess_method ? get_paired_devices_subprocess() : [IOBluetoothDevice pairedDevices]; +} + IOBluetoothDevice *get_device(char *id) { NSString *nsId = [NSString stringWithCString:id encoding:[NSString defaultCStringEncoding]]; @@ -316,7 +433,7 @@ bool parse_signed_long_arg(char *arg, long *number) { } else { NSMutableArray *searchDevices = [NSMutableArray new]; - NSArray *pairedDevices = [IOBluetoothDevice pairedDevices]; + NSArray *pairedDevices = get_paired_devices(); if (pairedDevices) { [searchDevices addObjectsFromArray:pairedDevices]; } @@ -651,6 +768,7 @@ bool parse_op_arg(const char *arg, OpFunc *op, const char **op_name) { return false; } + @interface DeviceNotificationRunLoopStopper : NSObject @end @implementation DeviceNotificationRunLoopStopper { @@ -730,6 +848,7 @@ void add_cmd(void *args, cmd cmd) { } FormatterFunc list_devices = list_devices_default; +bool use_subprocess_method = false; int main(int argc, char *argv[]) { signal(SIGABRT, handle_abort); @@ -742,6 +861,12 @@ int main(int argc, char *argv[]) { } } + // Check environment variable for system profiler usage + char *use_system_profiler_env = getenv("BLUEUTIL_USE_SYSTEM_PROFILER"); + if (use_system_profiler_env && 0 == strcmp(use_system_profiler_env, "1")) { + use_subprocess_method = true; + } + _NSSetLogCStringFunction(CustomNSLogOutput); if (!BTAvaliable()) { @@ -780,6 +905,8 @@ int main(int argc, char *argv[]) { arg_wait_connect, arg_wait_disconnect, arg_wait_rssi, + + arg_use_system_profiler, }; const char *optstring = "p::d::hv"; @@ -812,6 +939,8 @@ int main(int argc, char *argv[]) { {"wait-disconnect", required_argument, NULL, arg_wait_disconnect}, {"wait-rssi", required_argument, NULL, arg_wait_rssi}, + {"use-system-profiler", no_argument, NULL, arg_use_system_profiler}, + {"help", no_argument, NULL, arg_help}, {"version", no_argument, NULL, arg_version}, @@ -863,7 +992,7 @@ int main(int argc, char *argv[]) { } break; case arg_paired: { add_cmd(NULL, ^int(__unused void *_args) { - list_devices([IOBluetoothDevice pairedDevices], false); + list_devices(get_paired_devices(), false); return EXIT_SUCCESS; }); } break; @@ -928,7 +1057,7 @@ int main(int argc, char *argv[]) { case arg_connected: { add_cmd(NULL, ^int(__unused void *_args) { NSPredicate *predicate = [NSPredicate predicateWithFormat:@"isConnected == YES"]; - list_devices([[IOBluetoothDevice pairedDevices] filteredArrayUsingPredicate:predicate], false); + list_devices([get_paired_devices() filteredArrayUsingPredicate:predicate], false); return EXIT_SUCCESS; }); } break; @@ -1248,6 +1377,9 @@ int main(int argc, char *argv[]) { return EXIT_SUCCESS; }); } break; + case arg_use_system_profiler: { + use_subprocess_method = true; + } break; case arg_version: { printf(VERSION "\n"); return EXIT_SUCCESS; From 84af1da74262125212a033e7da0c72e894f3fb64 Mon Sep 17 00:00:00 2001 From: Arca Date: Sat, 2 Aug 2025 19:24:54 +0100 Subject: [PATCH 02/10] Address feedback --- README.md | 16 ++- blueutil.m | 278 +++++++++++++++++++++++++++++++++-------------------- 2 files changed, 187 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index a1d399b..7fb52c8 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,12 @@ Opening Bluetooth preference pane always turns on discoverability if bluetooth p ## Alternative Method -By default, `blueutil` uses IOBluetooth framework APIs to query paired devices. As an alternative, you can use the `--use-system-profiler` option or set the `BLUEUTIL_USE_SYSTEM_PROFILER=1` environment variable to use the `system_profiler` command instead. +By default, `blueutil` uses IOBluetooth framework APIs to query paired devices. As an alternative, you can set the `BLUEUTIL_USE_SYSTEM_PROFILER=1` environment variable to use the `system_profiler` command instead. The system_profiler method resolves an issue where some multi-point Bluetooth devices (devices that can connect to multiple devices simultaneously) may not report their connection status correctly through the IOBluetooth APIs, but do show the correct status via system_profiler. +**Note:** The system_profiler method is experimental and may have compatibility issues with some blueutil commands. + ## Usage @@ -48,7 +50,6 @@ Without options outputs current state --format FORMAT change output format of info and all listing commands - --use-system-profiler use system_profiler instead of IOBluetooth API for paired device queries --wait-connect ID [TIMEOUT] EXPERIMENTAL wait for device to connect @@ -77,7 +78,7 @@ Due to possible problems, blueutil will refuse to run as root user (see https:// Use environment variable BLUEUTIL_ALLOW_ROOT=1 to override (sudo BLUEUTIL_ALLOW_ROOT=1 blueutil …). Environment variables: - BLUEUTIL_USE_SYSTEM_PROFILER=1 use system_profiler instead of IOBluetooth API (same as --use-system-profiler) + BLUEUTIL_USE_SYSTEM_PROFILER=1 EXPERIMENTAL: use system_profiler instead of IOBluetoothDevice API for paired device queries Exit codes: 0 Success @@ -98,9 +99,14 @@ List paired devices using IOBluetooth API (default): blueutil --paired ``` -List paired devices using system_profiler: +Use system_profiler for a single command: +```sh +BLUEUTIL_USE_SYSTEM_PROFILER=1 blueutil --paired +``` + +Use system_profiler outside of shell (e.g., in scripts): ```sh -blueutil --use-system-profiler --paired +/usr/bin/env BLUEUTIL_USE_SYSTEM_PROFILER=1 blueutil --paired ``` Set environment variable to always use system_profiler: diff --git a/blueutil.m b/blueutil.m index 8abfa9b..f0b8b49 100644 --- a/blueutil.m +++ b/blueutil.m @@ -21,6 +21,8 @@ #define eprintf(...) fprintf(stderr, ##__VA_ARGS__) +static const char kUnreadableRSSI = (char)-129; // Value when RSSI cannot be read, matches IOBluetooth API + void *assert_alloc(void *pointer) { if (pointer == NULL) { eprintf("%s\n", strerror(errno)); @@ -53,13 +55,26 @@ int assert_reg(int errcode, const regex_t *restrict preg, char *reason) { // Mock IOBluetoothDevice for subprocess method @interface MockBluetoothDevice : NSObject -@property (nonatomic, strong) NSString *address; -@property (nonatomic, strong) NSString *name; -@property (nonatomic, assign) BOOL paired; -@property (nonatomic, assign) BOOL connected; -@property (nonatomic, assign) BOOL favorite; -@property (nonatomic, strong) NSDate *recentAccessDate; -@property (nonatomic, assign) int rssi; +@property (nonatomic, strong, readonly) NSString *address; +@property (nonatomic, strong, readonly) NSString *name; +@property (nonatomic, assign, readonly) BOOL paired; +@property (nonatomic, assign, readonly) BOOL connected; +@property (nonatomic, assign, readonly) BOOL favorite; +@property (nonatomic, strong, readonly) NSDate *recentAccessDate; +@property (nonatomic, assign, readonly) int rssi; +@property (nonatomic, strong, readonly) IOBluetoothDevice *realDevice; +- (instancetype)initWithAddress:(NSString *)address + name:(NSString *)name + paired:(BOOL)paired + connected:(BOOL)connected + favorite:(BOOL)favorite + recentAccessDate:(NSDate *)recentAccessDate + rssi:(int)rssi; +- (instancetype)initWithAddress:(NSString *)address + name:(NSString *)name + connected:(BOOL)connected + recentAccessDate:(NSDate *)recentAccessDate + rssi:(int)rssi; - (NSString *)addressString; - (BOOL)isConnected; - (BOOL)isPaired; @@ -70,15 +85,86 @@ - (char)rawRSSI; @end @implementation MockBluetoothDevice -- (NSString *)addressString { return self.address; } -- (BOOL)isConnected { return self.connected; } -- (BOOL)isPaired { return self.paired; } -- (BOOL)isFavorite { return self.favorite; } -- (BOOL)isIncoming { return NO; } // Default to master mode -- (char)RSSI { return (char)self.rssi; } -- (char)rawRSSI { return (char)self.rssi; } +- (instancetype)initWithAddress:(NSString *)address + name:(NSString *)name + paired:(BOOL)paired + connected:(BOOL)connected + favorite:(BOOL)favorite + recentAccessDate:(NSDate *)recentAccessDate + rssi:(int)rssi { + if (self = [super init]) { + _address = address; + _name = name; + _paired = paired; + _connected = connected; + _favorite = favorite; + _recentAccessDate = recentAccessDate; + _rssi = rssi; + _realDevice = [IOBluetoothDevice deviceWithAddressString:address]; + } + return self; +} + +- (instancetype)initWithAddress:(NSString *)address + name:(NSString *)name + connected:(BOOL)connected + recentAccessDate:(NSDate *)recentAccessDate + rssi:(int)rssi { + return [self initWithAddress:address + name:name + paired:YES // Default: always paired when using system_profiler method + connected:connected + favorite:NO // Default: always NO for system_profiler method + recentAccessDate:recentAccessDate + rssi:rssi]; +} + +- (NSString *)addressString { + return self.address; +} +- (BOOL)isConnected { + return self.connected; +} +- (BOOL)isPaired { + return self.paired; +} +- (BOOL)isFavorite { + return self.favorite; +} +- (BOOL)isIncoming { + return NO; +} // Default to master mode +- (char)RSSI { + return (char)self.rssi; +} +- (char)rawRSSI { + return (char)self.rssi; +} + +- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector { + NSMethodSignature *signature = [super methodSignatureForSelector:aSelector]; + if (!signature && self.realDevice) { + signature = [self.realDevice methodSignatureForSelector:aSelector]; + } + return signature; +} + +- (void)forwardInvocation:(NSInvocation *)invocation { + if (self.realDevice && [self.realDevice respondsToSelector:invocation.selector]) { + [invocation invokeWithTarget:self.realDevice]; + } else { + [super forwardInvocation:invocation]; + } +} + +- (BOOL)respondsToSelector:(SEL)aSelector { + return [super respondsToSelector:aSelector] || (self.realDevice && [self.realDevice respondsToSelector:aSelector]); +} @end +// forward declarations +NSArray *get_paired_devices_subprocess(); + // short names typedef int (*GetterFunc)(); typedef bool (*SetterFunc)(int); @@ -169,7 +255,6 @@ void usage(FILE *io) { "", " --format FORMAT change output format of info and all listing commands", "", - " --use-system-profiler use system_profiler for paired device queries", "", " --wait-connect ID [TIMEOUT]", " EXPERIMENTAL wait for device to connect", @@ -198,7 +283,7 @@ void usage(FILE *io) { "Use environment variable BLUEUTIL_ALLOW_ROOT=1 to override (sudo BLUEUTIL_ALLOW_ROOT=1 blueutil …).", "", "Environment variables:", - " BLUEUTIL_USE_SYSTEM_PROFILER=1 use system_profiler for paired device queries (same as --use-system-profiler)", + " BLUEUTIL_USE_SYSTEM_PROFILER=1 EXPERIMENTAL: use system_profiler instead of IOBluetoothDevice API for paired device queries", "", "Exit codes:", }; @@ -334,85 +419,6 @@ bool parse_signed_long_arg(char *arg, long *number) { } } -NSArray *get_paired_devices_subprocess() { - NSTask *task = [[NSTask alloc] init]; - task.launchPath = @"/usr/sbin/system_profiler"; - task.arguments = @[@"SPBluetoothDataType", @"-xml"]; - - NSPipe *pipe = [NSPipe pipe]; - task.standardOutput = pipe; - task.standardError = [NSPipe pipe]; - - [task launch]; - [task waitUntilExit]; - - NSData *data = [[pipe fileHandleForReading] readDataToEndOfFile]; - if (task.terminationStatus != 0 || data.length == 0) { - return @[]; - } - - NSError *error = nil; - NSArray *plist = [NSPropertyListSerialization propertyListWithData:data - options:NSPropertyListImmutable - format:nil - error:&error]; - if (error || !plist || plist.count == 0) { - return @[]; - } - - NSMutableArray *pairedDevices = [NSMutableArray array]; - - // Navigate the system_profiler XML structure to find paired devices - for (NSDictionary *item in plist) { - NSArray *items = item[@"_items"]; - if (!items) continue; - - for (NSDictionary *bluetoothItem in items) { - // Process connected devices - NSArray *connectedDevices = bluetoothItem[@"device_connected"]; - if (connectedDevices) { - for (NSDictionary *deviceDict in connectedDevices) { - for (NSString *deviceName in deviceDict) { - NSDictionary *deviceInfo = deviceDict[deviceName]; - MockBluetoothDevice *mockDevice = [[MockBluetoothDevice alloc] init]; - mockDevice.address = deviceInfo[@"device_address"] ?: @""; - mockDevice.name = deviceName; - mockDevice.paired = YES; - mockDevice.connected = YES; - mockDevice.favorite = NO; - mockDevice.recentAccessDate = [NSDate date]; - mockDevice.rssi = deviceInfo[@"device_rssi"] ? [deviceInfo[@"device_rssi"] intValue] : -129; - - [pairedDevices addObject:mockDevice]; - } - } - } - - // Process not connected devices - NSArray *notConnectedDevices = bluetoothItem[@"device_not_connected"]; - if (notConnectedDevices) { - for (NSDictionary *deviceDict in notConnectedDevices) { - for (NSString *deviceName in deviceDict) { - NSDictionary *deviceInfo = deviceDict[deviceName]; - MockBluetoothDevice *mockDevice = [[MockBluetoothDevice alloc] init]; - mockDevice.address = deviceInfo[@"device_address"] ?: @""; - mockDevice.name = deviceName; - mockDevice.paired = YES; - mockDevice.connected = NO; - mockDevice.favorite = NO; - mockDevice.recentAccessDate = [NSDate date]; - mockDevice.rssi = -129; // Not connected, so no RSSI - - [pairedDevices addObject:mockDevice]; - } - } - } - } - } - - return [pairedDevices copy]; -} - NSArray *get_paired_devices() { extern bool use_subprocess_method; return use_subprocess_method ? get_paired_devices_subprocess() : [IOBluetoothDevice pairedDevices]; @@ -768,7 +774,6 @@ bool parse_op_arg(const char *arg, OpFunc *op, const char **op_name) { return false; } - @interface DeviceNotificationRunLoopStopper : NSObject @end @implementation DeviceNotificationRunLoopStopper { @@ -905,8 +910,6 @@ int main(int argc, char *argv[]) { arg_wait_connect, arg_wait_disconnect, arg_wait_rssi, - - arg_use_system_profiler, }; const char *optstring = "p::d::hv"; @@ -939,7 +942,6 @@ int main(int argc, char *argv[]) { {"wait-disconnect", required_argument, NULL, arg_wait_disconnect}, {"wait-rssi", required_argument, NULL, arg_wait_rssi}, - {"use-system-profiler", no_argument, NULL, arg_use_system_profiler}, {"help", no_argument, NULL, arg_help}, {"version", no_argument, NULL, arg_version}, @@ -1377,9 +1379,6 @@ int main(int argc, char *argv[]) { return EXIT_SUCCESS; }); } break; - case arg_use_system_profiler: { - use_subprocess_method = true; - } break; case arg_version: { printf(VERSION "\n"); return EXIT_SUCCESS; @@ -1413,3 +1412,78 @@ int main(int argc, char *argv[]) { return EXIT_SUCCESS; } + +NSArray *get_paired_devices_subprocess() { + NSTask *task = [[NSTask alloc] init]; + task.launchPath = @"/usr/sbin/system_profiler"; + task.arguments = @[@"SPBluetoothDataType", @"-xml"]; + + NSPipe *pipe = [NSPipe pipe]; + task.standardOutput = pipe; + task.standardError = [NSPipe pipe]; + + [task launch]; + [task waitUntilExit]; + + NSData *data = [[pipe fileHandleForReading] readDataToEndOfFile]; + if (task.terminationStatus != 0 || data.length == 0) { + return @[]; + } + + NSError *error = nil; + NSArray *plist = [NSPropertyListSerialization propertyListWithData:data + options:NSPropertyListImmutable + format:nil + error:&error]; + if (error || !plist || plist.count == 0) { + return @[]; + } + + NSMutableArray *pairedDevices = [NSMutableArray array]; + + // Navigate the system_profiler XML structure to find paired devices + for (NSDictionary *item in plist) { + NSArray *items = item[@"_items"]; + if (!items) continue; + + for (NSDictionary *bluetoothItem in items) { + // Process connected devices + NSArray *connectedDevices = bluetoothItem[@"device_connected"]; + if (connectedDevices) { + for (NSDictionary *deviceDict in connectedDevices) { + for (NSString *deviceName in deviceDict) { + NSDictionary *deviceInfo = deviceDict[deviceName]; + MockBluetoothDevice *mockDevice = [[MockBluetoothDevice alloc] + initWithAddress:deviceInfo[@"device_address"] ?: @"" + name:deviceName + connected:YES + recentAccessDate:NULL + rssi:deviceInfo[@"device_rssi"] ? [deviceInfo[@"device_rssi"] intValue] : kUnreadableRSSI]; + + [pairedDevices addObject:mockDevice]; + } + } + } + + // Process not connected devices + NSArray *notConnectedDevices = bluetoothItem[@"device_not_connected"]; + if (notConnectedDevices) { + for (NSDictionary *deviceDict in notConnectedDevices) { + for (NSString *deviceName in deviceDict) { + NSDictionary *deviceInfo = deviceDict[deviceName]; + MockBluetoothDevice *mockDevice = + [[MockBluetoothDevice alloc] initWithAddress:deviceInfo[@"device_address"] ?: @"" + name:deviceName + connected:NO + recentAccessDate:NULL + rssi:kUnreadableRSSI]; // Not connected, so no RSSI + + [pairedDevices addObject:mockDevice]; + } + } + } + } + } + + return [pairedDevices copy]; +} From 3e6add260a69f22e74c2d81d559c69209163a7d0 Mon Sep 17 00:00:00 2001 From: Arca Date: Mon, 4 Aug 2025 00:18:42 +0100 Subject: [PATCH 03/10] Move get_paired_devices to eliminate extern variable --- blueutil.m | 156 ++++++++++++++++++++++++++--------------------------- 1 file changed, 78 insertions(+), 78 deletions(-) diff --git a/blueutil.m b/blueutil.m index f0b8b49..b4debbf 100644 --- a/blueutil.m +++ b/blueutil.m @@ -164,6 +164,7 @@ - (BOOL)respondsToSelector:(SEL)aSelector { // forward declarations NSArray *get_paired_devices_subprocess(); +NSArray *get_paired_devices(); // short names typedef int (*GetterFunc)(); @@ -419,9 +420,79 @@ bool parse_signed_long_arg(char *arg, long *number) { } } -NSArray *get_paired_devices() { - extern bool use_subprocess_method; - return use_subprocess_method ? get_paired_devices_subprocess() : [IOBluetoothDevice pairedDevices]; +NSArray *get_paired_devices_subprocess() { + NSTask *task = [[NSTask alloc] init]; + task.launchPath = @"/usr/sbin/system_profiler"; + task.arguments = @[@"SPBluetoothDataType", @"-xml"]; + + NSPipe *pipe = [NSPipe pipe]; + task.standardOutput = pipe; + task.standardError = [NSPipe pipe]; + + [task launch]; + [task waitUntilExit]; + + NSData *data = [[pipe fileHandleForReading] readDataToEndOfFile]; + if (task.terminationStatus != 0 || data.length == 0) { + return @[]; + } + + NSError *error = nil; + NSArray *plist = [NSPropertyListSerialization propertyListWithData:data + options:NSPropertyListImmutable + format:nil + error:&error]; + if (error || !plist || plist.count == 0) { + return @[]; + } + + NSMutableArray *pairedDevices = [NSMutableArray array]; + + // Navigate the system_profiler XML structure to find paired devices + for (NSDictionary *item in plist) { + NSArray *items = item[@"_items"]; + if (!items) continue; + + for (NSDictionary *bluetoothItem in items) { + // Process connected devices + NSArray *connectedDevices = bluetoothItem[@"device_connected"]; + if (connectedDevices) { + for (NSDictionary *deviceDict in connectedDevices) { + for (NSString *deviceName in deviceDict) { + NSDictionary *deviceInfo = deviceDict[deviceName]; + MockBluetoothDevice *mockDevice = [[MockBluetoothDevice alloc] + initWithAddress:deviceInfo[@"device_address"] ?: @"" + name:deviceName + connected:YES + recentAccessDate:NULL + rssi:deviceInfo[@"device_rssi"] ? [deviceInfo[@"device_rssi"] intValue] : kUnreadableRSSI]; + + [pairedDevices addObject:mockDevice]; + } + } + } + + // Process not connected devices + NSArray *notConnectedDevices = bluetoothItem[@"device_not_connected"]; + if (notConnectedDevices) { + for (NSDictionary *deviceDict in notConnectedDevices) { + for (NSString *deviceName in deviceDict) { + NSDictionary *deviceInfo = deviceDict[deviceName]; + MockBluetoothDevice *mockDevice = + [[MockBluetoothDevice alloc] initWithAddress:deviceInfo[@"device_address"] ?: @"" + name:deviceName + connected:NO + recentAccessDate:NULL + rssi:kUnreadableRSSI]; // Not connected, so no RSSI + + [pairedDevices addObject:mockDevice]; + } + } + } + } + } + + return [pairedDevices copy]; } IOBluetoothDevice *get_device(char *id) { @@ -855,6 +926,10 @@ void add_cmd(void *args, cmd cmd) { FormatterFunc list_devices = list_devices_default; bool use_subprocess_method = false; +NSArray *get_paired_devices() { + return use_subprocess_method ? get_paired_devices_subprocess() : [IOBluetoothDevice pairedDevices]; +} + int main(int argc, char *argv[]) { signal(SIGABRT, handle_abort); @@ -1412,78 +1487,3 @@ int main(int argc, char *argv[]) { return EXIT_SUCCESS; } - -NSArray *get_paired_devices_subprocess() { - NSTask *task = [[NSTask alloc] init]; - task.launchPath = @"/usr/sbin/system_profiler"; - task.arguments = @[@"SPBluetoothDataType", @"-xml"]; - - NSPipe *pipe = [NSPipe pipe]; - task.standardOutput = pipe; - task.standardError = [NSPipe pipe]; - - [task launch]; - [task waitUntilExit]; - - NSData *data = [[pipe fileHandleForReading] readDataToEndOfFile]; - if (task.terminationStatus != 0 || data.length == 0) { - return @[]; - } - - NSError *error = nil; - NSArray *plist = [NSPropertyListSerialization propertyListWithData:data - options:NSPropertyListImmutable - format:nil - error:&error]; - if (error || !plist || plist.count == 0) { - return @[]; - } - - NSMutableArray *pairedDevices = [NSMutableArray array]; - - // Navigate the system_profiler XML structure to find paired devices - for (NSDictionary *item in plist) { - NSArray *items = item[@"_items"]; - if (!items) continue; - - for (NSDictionary *bluetoothItem in items) { - // Process connected devices - NSArray *connectedDevices = bluetoothItem[@"device_connected"]; - if (connectedDevices) { - for (NSDictionary *deviceDict in connectedDevices) { - for (NSString *deviceName in deviceDict) { - NSDictionary *deviceInfo = deviceDict[deviceName]; - MockBluetoothDevice *mockDevice = [[MockBluetoothDevice alloc] - initWithAddress:deviceInfo[@"device_address"] ?: @"" - name:deviceName - connected:YES - recentAccessDate:NULL - rssi:deviceInfo[@"device_rssi"] ? [deviceInfo[@"device_rssi"] intValue] : kUnreadableRSSI]; - - [pairedDevices addObject:mockDevice]; - } - } - } - - // Process not connected devices - NSArray *notConnectedDevices = bluetoothItem[@"device_not_connected"]; - if (notConnectedDevices) { - for (NSDictionary *deviceDict in notConnectedDevices) { - for (NSString *deviceName in deviceDict) { - NSDictionary *deviceInfo = deviceDict[deviceName]; - MockBluetoothDevice *mockDevice = - [[MockBluetoothDevice alloc] initWithAddress:deviceInfo[@"device_address"] ?: @"" - name:deviceName - connected:NO - recentAccessDate:NULL - rssi:kUnreadableRSSI]; // Not connected, so no RSSI - - [pairedDevices addObject:mockDevice]; - } - } - } - } - } - - return [pairedDevices copy]; -} From 8f847b279d488b557b89815c5f9ce2e0f31a1c5e Mon Sep 17 00:00:00 2001 From: Arca Date: Mon, 4 Aug 2025 00:27:54 +0100 Subject: [PATCH 04/10] Remove forgotten newline --- README.md | 1 - blueutil.m | 1 - 2 files changed, 2 deletions(-) diff --git a/README.md b/README.md index 7fb52c8..39a6aa9 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,6 @@ Without options outputs current state --format FORMAT change output format of info and all listing commands - --wait-connect ID [TIMEOUT] EXPERIMENTAL wait for device to connect --wait-disconnect ID [TIMEOUT] diff --git a/blueutil.m b/blueutil.m index b4debbf..a1d97f7 100644 --- a/blueutil.m +++ b/blueutil.m @@ -1017,7 +1017,6 @@ int main(int argc, char *argv[]) { {"wait-disconnect", required_argument, NULL, arg_wait_disconnect}, {"wait-rssi", required_argument, NULL, arg_wait_rssi}, - {"help", no_argument, NULL, arg_help}, {"version", no_argument, NULL, arg_version}, From 486058544e7a025127e1509b094c68a67dcd503a Mon Sep 17 00:00:00 2001 From: Arca Date: Tue, 12 Aug 2025 11:19:52 +0100 Subject: [PATCH 05/10] Address feedback --- blueutil.m | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/blueutil.m b/blueutil.m index a1d97f7..a956075 100644 --- a/blueutil.m +++ b/blueutil.m @@ -21,7 +21,7 @@ #define eprintf(...) fprintf(stderr, ##__VA_ARGS__) -static const char kUnreadableRSSI = (char)-129; // Value when RSSI cannot be read, matches IOBluetooth API +static const char kUnreadableRSSI = 127; // Value when RSSI cannot be read, matches IOBluetooth API void *assert_alloc(void *pointer) { if (pointer == NULL) { @@ -61,7 +61,7 @@ @interface MockBluetoothDevice : NSObject @property (nonatomic, assign, readonly) BOOL connected; @property (nonatomic, assign, readonly) BOOL favorite; @property (nonatomic, strong, readonly) NSDate *recentAccessDate; -@property (nonatomic, assign, readonly) int rssi; +@property (nonatomic, assign, readonly) char rssi; @property (nonatomic, strong, readonly) IOBluetoothDevice *realDevice; - (instancetype)initWithAddress:(NSString *)address name:(NSString *)name @@ -69,12 +69,12 @@ - (instancetype)initWithAddress:(NSString *)address connected:(BOOL)connected favorite:(BOOL)favorite recentAccessDate:(NSDate *)recentAccessDate - rssi:(int)rssi; + rssi:(char)rssi; - (instancetype)initWithAddress:(NSString *)address name:(NSString *)name connected:(BOOL)connected recentAccessDate:(NSDate *)recentAccessDate - rssi:(int)rssi; + rssi:(char)rssi; - (NSString *)addressString; - (BOOL)isConnected; - (BOOL)isPaired; @@ -91,7 +91,7 @@ - (instancetype)initWithAddress:(NSString *)address connected:(BOOL)connected favorite:(BOOL)favorite recentAccessDate:(NSDate *)recentAccessDate - rssi:(int)rssi { + rssi:(char)rssi { if (self = [super init]) { _address = address; _name = name; @@ -109,7 +109,7 @@ - (instancetype)initWithAddress:(NSString *)address name:(NSString *)name connected:(BOOL)connected recentAccessDate:(NSDate *)recentAccessDate - rssi:(int)rssi { + rssi:(char)rssi { return [self initWithAddress:address name:name paired:YES // Default: always paired when using system_profiler method @@ -256,7 +256,6 @@ void usage(FILE *io) { "", " --format FORMAT change output format of info and all listing commands", "", - "", " --wait-connect ID [TIMEOUT]", " EXPERIMENTAL wait for device to connect", " --wait-disconnect ID [TIMEOUT]", From c5710e8571121bb111b81200824c1a2e2e44f5d8 Mon Sep 17 00:00:00 2001 From: Arca Date: Tue, 12 Aug 2025 11:21:07 +0100 Subject: [PATCH 06/10] Remove unused recentAccessDate constructor argument --- blueutil.m | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/blueutil.m b/blueutil.m index a956075..ddf0c8d 100644 --- a/blueutil.m +++ b/blueutil.m @@ -68,12 +68,10 @@ - (instancetype)initWithAddress:(NSString *)address paired:(BOOL)paired connected:(BOOL)connected favorite:(BOOL)favorite - recentAccessDate:(NSDate *)recentAccessDate rssi:(char)rssi; - (instancetype)initWithAddress:(NSString *)address name:(NSString *)name connected:(BOOL)connected - recentAccessDate:(NSDate *)recentAccessDate rssi:(char)rssi; - (NSString *)addressString; - (BOOL)isConnected; @@ -90,7 +88,6 @@ - (instancetype)initWithAddress:(NSString *)address paired:(BOOL)paired connected:(BOOL)connected favorite:(BOOL)favorite - recentAccessDate:(NSDate *)recentAccessDate rssi:(char)rssi { if (self = [super init]) { _address = address; @@ -98,7 +95,7 @@ - (instancetype)initWithAddress:(NSString *)address _paired = paired; _connected = connected; _favorite = favorite; - _recentAccessDate = recentAccessDate; + _recentAccessDate = NULL; _rssi = rssi; _realDevice = [IOBluetoothDevice deviceWithAddressString:address]; } @@ -108,14 +105,12 @@ - (instancetype)initWithAddress:(NSString *)address - (instancetype)initWithAddress:(NSString *)address name:(NSString *)name connected:(BOOL)connected - recentAccessDate:(NSDate *)recentAccessDate rssi:(char)rssi { return [self initWithAddress:address name:name paired:YES // Default: always paired when using system_profiler method connected:connected favorite:NO // Default: always NO for system_profiler method - recentAccessDate:recentAccessDate rssi:rssi]; } @@ -463,7 +458,6 @@ bool parse_signed_long_arg(char *arg, long *number) { initWithAddress:deviceInfo[@"device_address"] ?: @"" name:deviceName connected:YES - recentAccessDate:NULL rssi:deviceInfo[@"device_rssi"] ? [deviceInfo[@"device_rssi"] intValue] : kUnreadableRSSI]; [pairedDevices addObject:mockDevice]; @@ -481,7 +475,6 @@ bool parse_signed_long_arg(char *arg, long *number) { [[MockBluetoothDevice alloc] initWithAddress:deviceInfo[@"device_address"] ?: @"" name:deviceName connected:NO - recentAccessDate:NULL rssi:kUnreadableRSSI]; // Not connected, so no RSSI [pairedDevices addObject:mockDevice]; From 71fc99205172e1912b66a4ae4dbccfde5cc7fd69 Mon Sep 17 00:00:00 2001 From: Arca Date: Tue, 12 Aug 2025 11:23:12 +0100 Subject: [PATCH 07/10] Avoid hiding stderr output --- blueutil.m | 1 - 1 file changed, 1 deletion(-) diff --git a/blueutil.m b/blueutil.m index ddf0c8d..596ce13 100644 --- a/blueutil.m +++ b/blueutil.m @@ -421,7 +421,6 @@ bool parse_signed_long_arg(char *arg, long *number) { NSPipe *pipe = [NSPipe pipe]; task.standardOutput = pipe; - task.standardError = [NSPipe pipe]; [task launch]; [task waitUntilExit]; From 26009954fa8c661aeb6277c4a1a378679d69732a Mon Sep 17 00:00:00 2001 From: Arca Date: Tue, 12 Aug 2025 11:53:08 +0100 Subject: [PATCH 08/10] Better error handling --- blueutil.m | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/blueutil.m b/blueutil.m index 596ce13..9fd95f0 100644 --- a/blueutil.m +++ b/blueutil.m @@ -426,7 +426,11 @@ bool parse_signed_long_arg(char *arg, long *number) { [task waitUntilExit]; NSData *data = [[pipe fileHandleForReading] readDataToEndOfFile]; - if (task.terminationStatus != 0 || data.length == 0) { + if (task.terminationStatus != 0) { + eprintf("system_profiler failed with exit code %d\n", task.terminationStatus); + exit(EX_SOFTWARE); + } + if (data.length == 0) { return @[]; } From c138d76ae528b9d9deaaf936e36579d48c568719 Mon Sep 17 00:00:00 2001 From: Arca Date: Tue, 12 Aug 2025 11:55:36 +0100 Subject: [PATCH 09/10] Format code --- blueutil.m | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/blueutil.m b/blueutil.m index 9fd95f0..a50c176 100644 --- a/blueutil.m +++ b/blueutil.m @@ -69,10 +69,7 @@ - (instancetype)initWithAddress:(NSString *)address connected:(BOOL)connected favorite:(BOOL)favorite rssi:(char)rssi; -- (instancetype)initWithAddress:(NSString *)address - name:(NSString *)name - connected:(BOOL)connected - rssi:(char)rssi; +- (instancetype)initWithAddress:(NSString *)address name:(NSString *)name connected:(BOOL)connected rssi:(char)rssi; - (NSString *)addressString; - (BOOL)isConnected; - (BOOL)isPaired; @@ -102,10 +99,7 @@ - (instancetype)initWithAddress:(NSString *)address return self; } -- (instancetype)initWithAddress:(NSString *)address - name:(NSString *)name - connected:(BOOL)connected - rssi:(char)rssi { +- (instancetype)initWithAddress:(NSString *)address name:(NSString *)name connected:(BOOL)connected rssi:(char)rssi { return [self initWithAddress:address name:name paired:YES // Default: always paired when using system_profiler method @@ -458,10 +452,10 @@ bool parse_signed_long_arg(char *arg, long *number) { for (NSString *deviceName in deviceDict) { NSDictionary *deviceInfo = deviceDict[deviceName]; MockBluetoothDevice *mockDevice = [[MockBluetoothDevice alloc] - initWithAddress:deviceInfo[@"device_address"] ?: @"" - name:deviceName - connected:YES - rssi:deviceInfo[@"device_rssi"] ? [deviceInfo[@"device_rssi"] intValue] : kUnreadableRSSI]; + initWithAddress:deviceInfo[@"device_address"] ?: @"" + name:deviceName + connected:YES + rssi:deviceInfo[@"device_rssi"] ? [deviceInfo[@"device_rssi"] intValue] : kUnreadableRSSI]; [pairedDevices addObject:mockDevice]; } From b304fd2e6fcb0d4ffcba18557df36c69ac7bea88 Mon Sep 17 00:00:00 2001 From: Ivan Kuchin Date: Sun, 17 Aug 2025 23:31:23 +0200 Subject: [PATCH 10/10] little fixes and changelog entry --- CHANGELOG.md | 2 ++ blueutil.m | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c4bdd3..ae2a8b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## unreleased +* Experimental alternative method of fetching paired devices using system_profiler [#101](https://github.com/toy/blueutil/pull/101) [@arcaartem](https://github.com/arcaartem) + ## v2.12.0 (2025-02-02) * Hide debug log messages from IOBluetoothDeviceInquiry [@toy](https://github.com/toy) diff --git a/blueutil.m b/blueutil.m index a50c176..37178c7 100644 --- a/blueutil.m +++ b/blueutil.m @@ -124,10 +124,10 @@ - (BOOL)isIncoming { return NO; } // Default to master mode - (char)RSSI { - return (char)self.rssi; + return self.rssi; } - (char)rawRSSI { - return (char)self.rssi; + return self.rssi; } - (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector { @@ -152,7 +152,6 @@ - (BOOL)respondsToSelector:(SEL)aSelector { @end // forward declarations -NSArray *get_paired_devices_subprocess(); NSArray *get_paired_devices(); // short names @@ -419,11 +418,12 @@ bool parse_signed_long_arg(char *arg, long *number) { [task launch]; [task waitUntilExit]; - NSData *data = [[pipe fileHandleForReading] readDataToEndOfFile]; if (task.terminationStatus != 0) { eprintf("system_profiler failed with exit code %d\n", task.terminationStatus); exit(EX_SOFTWARE); } + + NSData *data = [[pipe fileHandleForReading] readDataToEndOfFile]; if (data.length == 0) { return @[]; }