Skip to content

Commit 62ff37a

Browse files
committed
Add macOS Bluetooth implementation via system_profiler
- MacBluetoothDevice in oshi-common: parses system_profiler SPBluetoothDataType output for connected/not-connected devices - Shared by JNA/FFM HALs (no native calls needed) - Replaces IOKit IOBluetoothDevice approach which only found kernel-level objects, not user-space paired devices - CHANGELOG.md: add entry for oshi#3255 - README.md, site index: USB Devices -> Peripheral devices (USB, Bluetooth) - Update parseMajorDeviceClass test for Miscellaneous/Uncategorized cases
1 parent 0aa2a61 commit 62ff37a

14 files changed

Lines changed: 286 additions & 198 deletions

File tree

oshi-common/src/main/java/oshi/hardware/BluetoothDevice.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@
1313
* <p>
1414
* Bluetooth devices are enumerated per adapter. Each device reports its name, MAC address, major device class,
1515
* connection/pairing state, and battery level when available.
16+
* <p>
17+
* Example usage:
18+
*
19+
* <pre>{@code
20+
* for (BluetoothDevice device : hal.getBluetoothDevices()) {
21+
* System.out.printf("%s [%s] %s%s (battery: %s)%n", device.getName(), device.getAddress(),
22+
* device.getMajorDeviceClass(), device.isConnected() ? " *connected*" : "",
23+
* device.getBatteryLevel() >= 0 ? device.getBatteryLevel() + "%" : "N/A");
24+
* }
25+
* }</pre>
1626
*/
1727
@PublicApi
1828
@Immutable

oshi-common/src/main/java/oshi/hardware/common/platform/linux/LinuxBluetoothDevice.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import java.util.List;
1111
import java.util.Locale;
1212
import java.util.Map;
13+
import java.util.regex.Pattern;
1314

1415
import oshi.annotation.concurrent.Immutable;
1516
import oshi.hardware.BluetoothDevice;
@@ -29,6 +30,7 @@ public final class LinuxBluetoothDevice extends AbstractBluetoothDevice {
2930

3031
private static final String SYS_BLUETOOTH = SysPath.SYS + "class/bluetooth/";
3132
private static final String VAR_LIB_BLUETOOTH = "/var/lib/bluetooth/";
33+
private static final Pattern MAC_PATTERN = Pattern.compile("(?i)([0-9a-f]{2}:){5}[0-9a-f]{2}");
3234

3335
private LinuxBluetoothDevice(String name, String address, String majorDeviceClass, boolean connected,
3436
boolean paired, int batteryLevel, String adapterName) {
@@ -71,7 +73,7 @@ static List<BluetoothDevice> queryBluetoothDevices(String sysBluetoothPath, Stri
7173
File adapterStateDir = new File(varLibPath + adapterAddress.toUpperCase(Locale.ROOT));
7274
if (!adapterStateDir.isDirectory()) {
7375
// Try lowercase (some systems)
74-
adapterStateDir = new File(varLibPath + adapterAddress);
76+
adapterStateDir = new File(varLibPath + adapterAddress.toLowerCase(Locale.ROOT));
7577
}
7678
if (!adapterStateDir.isDirectory()) {
7779
continue;
@@ -85,7 +87,7 @@ static List<BluetoothDevice> queryBluetoothDevices(String sysBluetoothPath, Stri
8587
for (File deviceDir : deviceDirs) {
8688
String dirName = deviceDir.getName();
8789
// Device directories are MAC addresses (XX:XX:XX:XX:XX:XX)
88-
if (!dirName.contains(":") || dirName.length() != 17) {
90+
if (!MAC_PATTERN.matcher(dirName).matches()) {
8991
continue;
9092
}
9193
File infoFile = new File(deviceDir, "info");
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
* Copyright 2026 The OSHI Project Contributors
3+
* SPDX-License-Identifier: MIT
4+
*/
5+
package oshi.hardware.common.platform.mac;
6+
7+
import java.util.ArrayList;
8+
import java.util.Collections;
9+
import java.util.List;
10+
import java.util.Locale;
11+
12+
import oshi.annotation.concurrent.Immutable;
13+
import oshi.hardware.BluetoothDevice;
14+
import oshi.hardware.common.AbstractBluetoothDevice;
15+
import oshi.util.ExecutingCommand;
16+
import oshi.util.ParseUtil;
17+
18+
/**
19+
* macOS Bluetooth device enumeration via {@code system_profiler SPBluetoothDataType}.
20+
*/
21+
@Immutable
22+
public final class MacBluetoothDevice extends AbstractBluetoothDevice {
23+
24+
private MacBluetoothDevice(String name, String address, String majorDeviceClass, boolean connected, boolean paired,
25+
int batteryLevel, String adapterName) {
26+
super(name, address, majorDeviceClass, connected, paired, batteryLevel, adapterName);
27+
}
28+
29+
/**
30+
* Gets Bluetooth devices known to the system.
31+
*
32+
* @return a list of {@link BluetoothDevice} objects
33+
*/
34+
public static List<BluetoothDevice> getBluetoothDevices() {
35+
return parseSystemProfiler(ExecutingCommand.runNative("system_profiler SPBluetoothDataType"));
36+
}
37+
38+
/**
39+
* Parses the output of {@code system_profiler SPBluetoothDataType}.
40+
*
41+
* @param lines the output lines
42+
* @return a list of Bluetooth devices
43+
*/
44+
static List<BluetoothDevice> parseSystemProfiler(List<String> lines) {
45+
List<BluetoothDevice> devices = new ArrayList<>();
46+
boolean inConnected = false;
47+
boolean inNotConnected = false;
48+
boolean inDevice = false;
49+
String name = "";
50+
String address = "";
51+
String majorClass = "";
52+
int batteryLevel = -1;
53+
54+
for (String line : lines) {
55+
String trimmed = line.trim();
56+
if (trimmed.isEmpty()) {
57+
continue;
58+
}
59+
// Section headers (indented with 6 spaces in system_profiler output)
60+
if (trimmed.startsWith("Connected:") || trimmed.equals("Devices (Paired, Configured, & Connected):")) {
61+
if (inDevice) {
62+
devices.add(new MacBluetoothDevice(name, address, majorClass, inConnected, true, batteryLevel, ""));
63+
}
64+
inConnected = true;
65+
inNotConnected = false;
66+
inDevice = false;
67+
continue;
68+
}
69+
if (trimmed.startsWith("Not Connected:") || trimmed.equals("Devices (Paired, Not Connected):")) {
70+
if (inDevice) {
71+
devices.add(new MacBluetoothDevice(name, address, majorClass, inConnected, true, batteryLevel, ""));
72+
}
73+
inConnected = false;
74+
inNotConnected = true;
75+
inDevice = false;
76+
continue;
77+
}
78+
if (!inConnected && !inNotConnected) {
79+
continue;
80+
}
81+
82+
// Device entries are names followed by a colon at a certain indent level
83+
// Properties are key: value pairs at deeper indent
84+
String[] parts = trimmed.split(":", 2);
85+
if (parts.length == 2) {
86+
String key = parts[0].trim();
87+
String value = parts[1].trim();
88+
89+
if (value.isEmpty() && !key.startsWith("Address") && !key.startsWith("Minor")) {
90+
// This is a device name header
91+
if (inDevice) {
92+
devices.add(
93+
new MacBluetoothDevice(name, address, majorClass, inConnected, true, batteryLevel, ""));
94+
}
95+
inDevice = true;
96+
name = key;
97+
address = "";
98+
majorClass = "";
99+
batteryLevel = -1;
100+
} else if (inDevice) {
101+
switch (key.toLowerCase(Locale.ROOT)) {
102+
case "address":
103+
address = value.toUpperCase(Locale.ROOT);
104+
break;
105+
case "major type":
106+
case "minor type":
107+
majorClass = value;
108+
break;
109+
case "battery level":
110+
String digits = value.replace("%", "").trim();
111+
batteryLevel = ParseUtil.parseIntOrDefault(digits, -1);
112+
break;
113+
default:
114+
break;
115+
}
116+
}
117+
}
118+
}
119+
// Add last device
120+
if (inDevice) {
121+
devices.add(new MacBluetoothDevice(name, address, majorClass, inConnected, true, batteryLevel, ""));
122+
}
123+
return Collections.unmodifiableList(devices);
124+
}
125+
}

oshi-common/src/main/java/oshi/hardware/common/platform/mac/MacHardwareAbstractionLayer.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import java.util.List;
88

99
import oshi.annotation.concurrent.ThreadSafe;
10+
import oshi.hardware.BluetoothDevice;
1011
import oshi.hardware.LogicalVolumeGroup;
1112
import oshi.hardware.SoundCard;
1213
import oshi.hardware.common.AbstractHardwareAbstractionLayer;
@@ -32,4 +33,9 @@ public List<LogicalVolumeGroup> getLogicalVolumeGroups() {
3233
public List<SoundCard> getSoundCards() {
3334
return MacSoundCard.getSoundCards();
3435
}
36+
37+
@Override
38+
public List<BluetoothDevice> getBluetoothDevices() {
39+
return MacBluetoothDevice.getBluetoothDevices();
40+
}
3541
}

oshi-common/src/main/java/oshi/util/FormatUtil.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,4 +221,43 @@ public static String formatError(int errorCode) {
221221
public static int roundToInt(double x) {
222222
return (int) Math.round(x);
223223
}
224+
225+
/**
226+
* Formats a 6-byte MAC address stored in the lower 48 bits of a long to colon-separated uppercase hex (e.g.,
227+
* {@code AA:BB:CC:DD:EE:FF}).
228+
*
229+
* @param addr the MAC address as a long (lower 48 bits)
230+
* @return the formatted MAC address string
231+
*/
232+
public static String formatMacAddress(long addr) {
233+
return String.format(Locale.ROOT, "%02X:%02X:%02X:%02X:%02X:%02X", (addr >> 40) & 0xFF, (addr >> 32) & 0xFF,
234+
(addr >> 24) & 0xFF, (addr >> 16) & 0xFF, (addr >> 8) & 0xFF, addr & 0xFF);
235+
}
236+
237+
/**
238+
* Normalizes a MAC address string (which may use dashes, colons, or no separators) to colon-separated uppercase hex
239+
* (e.g., {@code AA:BB:CC:DD:EE:FF}).
240+
*
241+
* @param raw the raw MAC address string
242+
* @return the normalized MAC address, or the original string uppercased if it cannot be parsed as 6 bytes
243+
*/
244+
public static String formatMacAddress(String raw) {
245+
String cleaned = raw.replace("-", "").replace(":", "").trim();
246+
if (cleaned.length() == 12 && isHex(cleaned)) {
247+
return String.format(Locale.ROOT, "%s:%s:%s:%s:%s:%s", cleaned.substring(0, 2), cleaned.substring(2, 4),
248+
cleaned.substring(4, 6), cleaned.substring(6, 8), cleaned.substring(8, 10),
249+
cleaned.substring(10, 12)).toUpperCase(Locale.ROOT);
250+
}
251+
return raw.toUpperCase(Locale.ROOT);
252+
}
253+
254+
private static boolean isHex(String s) {
255+
for (int i = 0; i < s.length(); i++) {
256+
char c = s.charAt(i);
257+
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) {
258+
return false;
259+
}
260+
}
261+
return true;
262+
}
224263
}

oshi-common/src/test/java/oshi/util/FormatUtilTest.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,4 +157,19 @@ void testRoundToInt() {
157157
assertThat("Improper rounding 1", FormatUtil.roundToInt(1d), is(1));
158158
}
159159

160+
@Test
161+
void testFormatMacAddressFromLong() {
162+
assertThat(FormatUtil.formatMacAddress(0xAABBCCDDEEFFL), is("AA:BB:CC:DD:EE:FF"));
163+
assertThat(FormatUtil.formatMacAddress(0x000000000000L), is("00:00:00:00:00:00"));
164+
assertThat(FormatUtil.formatMacAddress(0x112233445566L), is("11:22:33:44:55:66"));
165+
}
166+
167+
@Test
168+
void testFormatMacAddressFromString() {
169+
assertThat(FormatUtil.formatMacAddress("aa-bb-cc-dd-ee-ff"), is("AA:BB:CC:DD:EE:FF"));
170+
assertThat(FormatUtil.formatMacAddress("11:22:33:44:55:66"), is("11:22:33:44:55:66"));
171+
assertThat(FormatUtil.formatMacAddress("aabbccddeeff"), is("AA:BB:CC:DD:EE:FF"));
172+
assertThat(FormatUtil.formatMacAddress("abcd"), is("ABCD"));
173+
}
174+
160175
}

oshi-core-ffm/src/main/java/oshi/hardware/platform/mac/MacBluetoothDeviceFFM.java

Lines changed: 0 additions & 81 deletions
This file was deleted.

oshi-core-ffm/src/main/java/oshi/hardware/platform/mac/MacHardwareAbstractionLayerFFM.java

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
import oshi.annotation.concurrent.ThreadSafe;
1010
import oshi.ffm.unix.CupsPrinterFFM;
11-
import oshi.hardware.BluetoothDevice;
1211
import oshi.hardware.CentralProcessor;
1312
import oshi.hardware.ComputerSystem;
1413
import oshi.hardware.Display;
@@ -78,9 +77,4 @@ public List<UsbDevice> getUsbDevices(boolean tree) {
7877
public List<GraphicsCard> getGraphicsCards() {
7978
return MacGraphicsCardFFM.getGraphicsCards();
8079
}
81-
82-
@Override
83-
public List<BluetoothDevice> getBluetoothDevices() {
84-
return MacBluetoothDeviceFFM.getBluetoothDevices();
85-
}
8680
}

0 commit comments

Comments
 (0)