diff --git a/.gitignore b/.gitignore index 42311f0..34caa38 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ __pycache__/ *.egg-info/ dist/ build/ +cpp/build*/ .eggs/ .venv/ .pytest_cache/ diff --git a/Makefile b/Makefile index 4c4ab33..ea6a3a7 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,12 @@ PYTHON = $(VENV)/bin/python $(VENV)/bin/activate: python3 -m venv $(VENV) + $(PIP) install -q --upgrade pip $(PIP) install -q pytest + @if [ "$$(uname -s)" = "Darwin" ]; then \ + echo "Installing macOS Bluetooth dependencies (PyObjC) in virtualenv..."; \ + $(PIP) install -q pyobjc-core pyobjc-framework-Cocoa pyobjc-framework-IOBluetooth; \ + fi python-setup: $(VENV)/bin/activate ## Set up Python virtualenv $(PIP) install -q -e $(PYTHON_DIR) diff --git a/README.md b/README.md index e1e75b1..1efcd39 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ [![Python 3](https://img.shields.io/badge/Python-3-3572A5.svg)](python/) [![Rust](https://img.shields.io/badge/Rust-1.70+-DEA584.svg)](rust/) [![C++17](https://img.shields.io/badge/C++-17-f34b7d.svg)](cpp/) -[![Platform: Linux](https://img.shields.io/badge/Platform-Linux-orange.svg)](https://kernel.org) +[![Platform: Linux / macOS](https://img.shields.io/badge/Platform-Linux%20%7C%20macOS-blue.svg)](#) -**Control Bose headphones from Linux — no app, no cloud, no account.** +**Control Bose headphones from Linux and macOS — no app, no cloud, no account.** ![bosectl CLI](docs/media/screenshot.png) @@ -23,10 +23,10 @@ connection to the headphones. ## Supported Devices -| Device | NC Control | EQ | Spatial | Profiles | Buttons | Status | -|--------|-----------|-----|---------|----------|---------|--------| -| **QC Ultra Headphones 2** | CNC 0-10 slider | 3-band | room/head | 7 custom slots | Shortcut remap | Verified | -| **QuietComfort 35 / 35 II** | ANR off/high/wind/low | — | — | — | Action remap (VPA/ANC) | Verified | +| Device | NC Control | EQ | Spatial | Profiles | Buttons | Status | +| --------------------------- | --------------------- | ------ | --------- | -------------- | ---------------------- | -------- | +| **QC Ultra Headphones 2** | CNC 0-10 slider | 3-band | room/head | 7 custom slots | Shortcut remap | Verified | +| **QuietComfort 35 / 35 II** | ANR off/high/wind/low | — | — | — | Action remap (VPA/ANC) | Verified | ### Device Roadmap @@ -34,17 +34,17 @@ The library includes a [device catalog](docs/architecture.md#device-catalog) of all known BMAP-capable Bose products. These are recognized by Bluetooth product ID but don't have tested configurations yet — contributions welcome: -| Device | Codename | Category | PID | -|--------|----------|----------|-----| -| Noise Cancelling Headphones 700 | goodyear | Headphones | `0x4024` | -| QuietComfort 45 | duran | Headphones | `0x4039` | -| QuietComfort Headphones | prince | Headphones | `0x4075` | -| QuietComfort Ultra Headphones | lonestarr | Headphones | `0x4066` | -| QuietComfort Earbuds II | smalls | Earbuds | `0x4064` | -| QuietComfort Ultra Earbuds | scotty | Earbuds | `0x4072` | -| Ultra Open Earbuds | serena | Earbuds | `0x4068` | -| SoundLink Flex | phelps | Speaker | `0xBC59` | -| SoundLink Flex 2 | mathers | Speaker | `0xBC61` | +| Device | Codename | Category | PID | +| ------------------------------- | --------- | ---------- | -------- | +| Noise Cancelling Headphones 700 | goodyear | Headphones | `0x4024` | +| QuietComfort 45 | duran | Headphones | `0x4039` | +| QuietComfort Headphones | prince | Headphones | `0x4075` | +| QuietComfort Ultra Headphones | lonestarr | Headphones | `0x4066` | +| QuietComfort Earbuds II | smalls | Earbuds | `0x4064` | +| QuietComfort Ultra Earbuds | scotty | Earbuds | `0x4072` | +| Ultra Open Earbuds | serena | Earbuds | `0x4068` | +| SoundLink Flex | phelps | Speaker | `0xBC59` | +| SoundLink Flex 2 | mathers | Speaker | `0xBC61` | Adding a new device is a configuration entry — no library code changes needed. See [Adding a New Device](docs/architecture.md#adding-a-new-device). @@ -120,11 +120,29 @@ pybmap.known_devices() # full catalog ### Prerequisites +#### Linux + - **Linux** with BlueZ (standard Bluetooth stack) - **Bluetooth** adapter (built-in or USB) - **Bose headphones** paired via `bluetoothctl` -### From Release Binaries +#### macOS + +- **macOS** 10.15+ +- **Bluetooth** enabled in System Settings +- **Bose headphones** paired/connected via System Settings + +### Installation (macOS & Linux Python CLI) + +Run the included installer to set up dependencies (such as PyObjC on macOS) and symlink `bosectl` globally: + +```bash +git clone https://github.com/aaronsb/bosectl.git +cd bosectl +./macOS_install.sh +``` + +### From Release Binaries (Linux) ```bash # Download from GitHub releases @@ -140,7 +158,7 @@ sudo cp bmapctl-rust-linux-x86_64 /usr/local/bin/bmapctl ```bash git clone https://github.com/aaronsb/bosectl.git cd bosectl -make test # Run all tests (Python + Rust + C++) +make test # Run all tests (Python + Rust + C++; automatically installs macOS test deps and handles missing toolchains) make artifacts # Build release binaries + SHA256SUMS ``` @@ -217,7 +235,7 @@ Full protocol reference: **[NOTES.md](NOTES.md)** ## Building & Releasing ```bash -make test # All tests (119 Python, 59 Rust, 51 C++) +make test # All tests (121 Python, 63 Rust, 54 C++) make artifacts # Build + strip + SHA256SUMS in dist/ make release VERSION=v0.2.0 # Test → build → gh release create make clean # Remove all build artifacts diff --git a/bosectl b/bosectl index ff0524c..45f3bb4 100755 --- a/bosectl +++ b/bosectl @@ -12,7 +12,7 @@ import sys try: import pybmap # noqa: F401 except ImportError: - sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "python")) + sys.path.insert(0, os.path.join(os.path.dirname(os.path.realpath(__file__)), "python")) from pybmap.cli import main main() diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 8d020d2..aacd44e 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -27,10 +27,21 @@ endif() # CLI add_executable(bmapctl src/main.cpp) target_compile_definitions(bmapctl PRIVATE GIT_HASH="${GIT_HASH}" BMAP_VERSION="${PROJECT_VERSION}") -target_link_libraries(bmapctl PRIVATE bmap bluetooth) +if(APPLE) + target_link_libraries(bmapctl PRIVATE bmap) +else() + target_link_libraries(bmapctl PRIVATE bmap bluetooth) +endif() # Tests enable_testing() -add_executable(bmap_tests tests/test_main.cpp tests/test_protocol.cpp tests/test_parsers.cpp tests/test_connection.cpp tests/test_catalog.cpp) +add_executable(bmap_tests + tests/test_main.cpp + tests/test_protocol.cpp + tests/test_parsers.cpp + tests/test_connection.cpp + tests/test_catalog.cpp + tests/test_transport.cpp +) target_link_libraries(bmap_tests PRIVATE bmap) add_test(NAME bmap_tests COMMAND bmap_tests) diff --git a/cpp/src/discovery.cpp b/cpp/src/discovery.cpp index c41e8b8..ce30c7f 100644 --- a/cpp/src/discovery.cpp +++ b/cpp/src/discovery.cpp @@ -2,6 +2,7 @@ #include "discovery.h" #include "catalog.h" +#ifndef __APPLE__ #include #include #include @@ -72,3 +73,12 @@ std::optional> find_bmap_device() { } } // namespace bmap +#else // __APPLE__ +namespace bmap { + +std::optional> find_bmap_device() { + return std::nullopt; +} + +} // namespace bmap +#endif diff --git a/cpp/src/transport.cpp b/cpp/src/transport.cpp index 43d6084..fa8951f 100644 --- a/cpp/src/transport.cpp +++ b/cpp/src/transport.cpp @@ -1,6 +1,7 @@ // RFCOMM transport implementation using raw Linux Bluetooth sockets. #include "transport.h" +#ifndef __APPLE__ #include #include #include @@ -100,3 +101,31 @@ void RfcommTransport::set_recv_timeout(int ms) { } } // namespace bmap + +#else // __APPLE__ +#include + +namespace bmap { + +RfcommTransport::RfcommTransport(const std::string&, uint8_t) { + throw std::runtime_error("Bluetooth RFCOMM sockets not supported on macOS in C++ library. Use Python bosectl instead."); +} + +RfcommTransport::~RfcommTransport() {} + +std::vector RfcommTransport::send_recv(const std::vector&) { + throw std::runtime_error("Not supported on macOS"); +} + +std::vector RfcommTransport::send_recv_drain(const std::vector&) { + throw std::runtime_error("Not supported on macOS"); +} + +std::vector RfcommTransport::send_recv_inner(const std::vector&, bool) { + throw std::runtime_error("Not supported on macOS"); +} + +void RfcommTransport::set_recv_timeout(int) {} + +} // namespace bmap +#endif diff --git a/cpp/tests/test_transport.cpp b/cpp/tests/test_transport.cpp new file mode 100644 index 0000000..dcf26c2 --- /dev/null +++ b/cpp/tests/test_transport.cpp @@ -0,0 +1,24 @@ +#include "test_common.h" +#include "../src/transport.h" +#include "../src/discovery.h" +#include + +using namespace bmap; + +#ifdef __APPLE__ +TEST(macos_transport_throws) { + bool threw = false; + try { + RfcommTransport transport("00:11:22:33:44:55", 2); + } catch (const std::runtime_error& e) { + threw = true; + ASSERT_TRUE(std::string(e.what()).find("not supported on macOS") != std::string::npos); + } + ASSERT_TRUE(threw); +} + +TEST(macos_discovery_returns_nullopt) { + auto dev = find_bmap_device(); + ASSERT_FALSE(dev.has_value()); +} +#endif diff --git a/macOS_install.sh b/macOS_install.sh new file mode 100755 index 0000000..0c6347e --- /dev/null +++ b/macOS_install.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# bosectl macOS Installer + +set -e + +echo "=== bosectl macOS Installer ===" +echo "" + +# 1. Install dependencies +echo "Installing macOS Bluetooth dependencies (PyObjC)..." +# ponytail: unpinned — 9.2 has no wheels for Python 3.13+; pin again only if an API break shows up +python3 -m pip install --user pyobjc-core pyobjc-framework-Cocoa pyobjc-framework-IOBluetooth + +# 2. Make bosectl script executable +echo "Configuring executable permissions..." +chmod +x bosectl + +# 3. Create symlink in /usr/local/bin +echo "" +echo "To make 'bosectl' accessible from anywhere on your system," +echo "we will create a symlink in /usr/local/bin/bosectl." +echo "This requires administrator privileges." +echo "" + +if sudo ln -sf "$(pwd)/bosectl" /usr/local/bin/bosectl; then + echo "" + echo "=== Installation Successful! ===" + echo "You can now run 'bosectl' from any terminal session." +else + echo "" + echo "=== Symlink Failed ===" + echo "Could not create symlink in /usr/local/bin." + echo "You can still run it locally using: $(pwd)/bosectl" +fi diff --git a/python/pybmap/discovery.py b/python/pybmap/discovery.py index c7e7893..b09b5b7 100644 --- a/python/pybmap/discovery.py +++ b/python/pybmap/discovery.py @@ -1,82 +1,106 @@ -"""Auto-detect paired BMAP devices via bluetoothctl (Linux).""" - -import re -import subprocess - -from .catalog import BMAP_UUID, lookup_device - - -def find_bmap_device(): - """Auto-detect a paired, connected BMAP-capable Bluetooth device. - - Prioritizes connected devices over paired-but-disconnected ones. - Returns (mac, device_type) tuple, or (None, None) if not found. - """ - candidates = _scan_paired_devices() - - # Prefer connected devices - for mac, device_type, connected in candidates: - if connected: +"""Auto-detect paired BMAP devices (Linux/macOS).""" + +import sys + +if sys.platform == "darwin": + from IOBluetooth import IOBluetoothDevice + from .catalog import lookup_device + + def find_bmap_device(): + """Auto-detect a paired, connected BMAP-capable Bluetooth device on macOS. + + Prioritizes connected devices over paired-but-disconnected ones. + Returns (mac, device_type) tuple, or (None, None) if not found. + """ + candidates = [] + try: + for device in IOBluetoothDevice.pairedDevices(): + pid = device.productID() + entry = lookup_device(pid) + if entry and entry.config: + # device.getAddressString() returns something like "68-f2-1f-0d-f5-11" + candidates.append((device.getAddressString(), entry.config, device.isConnected())) + except Exception: + pass + + # Prefer connected devices + for mac, device_type, connected in candidates: + if connected: + return (mac, device_type) + + # Fall back to first paired BMAP device + for mac, device_type, connected in candidates: return (mac, device_type) - # Fall back to first paired BMAP device - for mac, device_type, connected in candidates: - return (mac, device_type) - - return (None, None) - - -def _scan_paired_devices(): - """Scan paired Bluetooth devices for BMAP-capable headphones. - - Returns list of (mac, device_type, is_connected) tuples. - """ - candidates = [] - try: - result = subprocess.run( - ["bluetoothctl", "devices", "Paired"], - capture_output=True, text=True, timeout=5, - ) - for line in result.stdout.strip().splitlines(): - parts = line.split(None, 2) - if len(parts) < 2: - continue - mac = parts[1] - info = subprocess.run( - ["bluetoothctl", "info", mac], - capture_output=True, text=True, timeout=3, - ) - info_text = info.stdout + return (None, None) - # Must be an audio device with the BMAP UUID - is_audio = ("audio-headset" in info_text or "audio-headphones" in info_text) - has_bmap = BMAP_UUID in info_text - if not (is_audio and has_bmap): - continue +else: + import re + import subprocess + from .catalog import BMAP_UUID, lookup_device - connected = "Connected: yes" in info_text + def find_bmap_device(): + """Auto-detect a paired, connected BMAP-capable Bluetooth device (Linux). - # Determine device type from Modalias product ID - device_type = _detect_device_type(info_text) + Prioritizes connected devices over paired-but-disconnected ones. + Returns (mac, device_type) tuple, or (None, None) if not found. + """ + candidates = _scan_paired_devices() - candidates.append((mac, device_type, connected)) - except (FileNotFoundError, subprocess.TimeoutExpired): - pass - return candidates + # Prefer connected devices + for mac, device_type, connected in candidates: + if connected: + return (mac, device_type) + # Fall back to first paired BMAP device + for mac, device_type, connected in candidates: + return (mac, device_type) -def _detect_device_type(info_text): - """Extract device type from bluetoothctl info output via catalog lookup. + return (None, None) - Parses Modalias (bluetooth:vXXXXpYYYYdZZZZ) to get the product ID, - then looks it up in the device catalog. - Falls back to "qc_ultra2" if the product ID is unknown. - """ - match = re.search(r"Modalias:\s*bluetooth:v[0-9A-Fa-f]{4}p([0-9A-Fa-f]{4})", info_text) - if match: - product_id = int(match.group(1), 16) - entry = lookup_device(product_id) - if entry and entry.config: - return entry.config - return "qc_ultra2" # default for unknown BMAP devices + def _scan_paired_devices(): + """Scan paired Bluetooth devices for BMAP-capable headphones.""" + candidates = [] + try: + result = subprocess.run( + ["bluetoothctl", "devices", "Paired"], + capture_output=True, text=True, timeout=5, + ) + for line in result.stdout.strip().splitlines(): + parts = line.split(None, 2) + if len(parts) < 2: + continue + mac = parts[1] + info = subprocess.run( + ["bluetoothctl", "info", mac], + capture_output=True, text=True, timeout=3, + ) + info_text = info.stdout + + # Must be an audio device with the BMAP UUID + is_audio = ("audio-headset" in info_text or "audio-headphones" in info_text) + has_bmap = BMAP_UUID in info_text + if not (is_audio and has_bmap): + continue + + connected = "Connected: yes" in info_text + + # Determine device type from Modalias product ID + device_type = _detect_device_type(info_text) + + candidates.append((mac, device_type, connected)) + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + return candidates + + + def _detect_device_type(info_text): + """Extract device type from bluetoothctl info output via catalog lookup.""" + match = re.search(r"Modalias:\s*bluetooth:v[0-9A-Fa-f]{4}p([0-9A-Fa-f]{4})", info_text) + if match: + product_id = int(match.group(1), 16) + entry = lookup_device(product_id) + if entry and entry.config: + return entry.config + return "qc_ultra2" # default for unknown BMAP devices diff --git a/python/pybmap/transport.py b/python/pybmap/transport.py index c4aa352..a612239 100644 --- a/python/pybmap/transport.py +++ b/python/pybmap/transport.py @@ -1,97 +1,292 @@ """RFCOMM Bluetooth socket transport for BMAP devices.""" -import socket +import sys import time - from .errors import BmapConnectionError, BmapTimeoutError RFCOMM_CHANNEL = 2 # BMAP protocol is always on RFCOMM channel 2 -class RfcommTransport: - """Raw RFCOMM Bluetooth socket transport. - - Manages the Bluetooth socket lifecycle and provides send/receive - with optional multi-packet draining for async responses. - - Usage: - with RfcommTransport("68:F2:1F:XX:XX:XX") as transport: - resp = transport.send_recv(packet_bytes) - """ - - def __init__(self, mac, channel=RFCOMM_CHANNEL, timeout=3.0): - self.mac = mac - self.channel = channel - self.timeout = timeout - self._sock = None - - def connect(self): - """Open the RFCOMM socket to the device.""" - try: - self._sock = socket.socket( - socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM - ) - self._sock.settimeout(self.timeout) - self._sock.connect((self.mac, self.channel)) - except (OSError, socket.error) as e: - self._sock = None - raise BmapConnectionError( - "Failed to connect to %s: %s" % (self.mac, e) - ) from e +if sys.platform == "darwin": + import objc + from IOBluetooth import ( + IOBluetoothDevice, + IOBluetoothRFCOMMChannel, + NSObject, + ) + from Foundation import NSRunLoop, NSDate, NSDefaultRunLoopMode + from queue import Queue + from threading import Event + + # Register metadata for openRFCOMMChannelSync to make sure out parameters are handled correctly + objc.registerMetaDataForSelector( + b"IOBluetoothDevice", + b"openRFCOMMChannelSync:withChannelID:delegate:", + dict( + arguments={ + 2: dict(type=objc._C_PTR + objc._C_ID, type_modifier=objc._C_OUT), + 3: dict(type=objc._C_USHT), # Channel ID + 4: dict(type=objc._C_ID) # Delegate + } + ) + ) + + class MacOsRfcommDelegate(NSObject): + def init(self): + self = objc.super(MacOsRfcommDelegate, self).init() + if self is None: + return None + self.baseband_connected = False + self.baseband_status = -1 + self.baseband_event = Event() + + self.channel_connected = False + self.channel_status = -1 + self.channel_event = Event() + + self.received_queue = Queue() + self.closed_event = Event() + return self + + def connectionComplete_status_(self, device, status): + self.baseband_status = status + self.baseband_connected = (status == 0) + self.baseband_event.set() - def close(self): - """Close the socket.""" - if self._sock: + def rfcommChannelOpenComplete_status_(self, channel, status): + self.channel_status = status + self.channel_connected = (status == 0) + self.channel_event.set() + + @objc.typedSelector(b"v@:@^vQ") + def rfcommChannelData_data_length_(self, channel, data_ptr, length): try: - self._sock.close() - except OSError: - pass - self._sock = None + # Convert objc.varlist to python bytes + chunk = bytes(data_ptr.as_buffer(length)) + self.received_queue.put(chunk) + except Exception: + try: + chunk = bytes(data_ptr[:length]) + self.received_queue.put(chunk) + except Exception: + pass - def send_recv(self, packet, drain=False): - """Send a BMAP packet and receive the response. - - Args: - packet: Raw bytes to send. - drain: If True, keep reading until the socket times out. - Needed for commands that return multiple STATUS - messages (e.g., GetAll). - - Returns: - Raw response bytes (may contain multiple concatenated packets - if drain=True). - - Raises: - BmapTimeoutError: If no response is received. - """ - if not self._sock: - raise BmapConnectionError("Not connected") - try: - self._sock.send(packet) - time.sleep(0.2) - data = self._sock.recv(4096) - except socket.timeout: - raise BmapTimeoutError("No response from device") - except OSError as e: - raise BmapConnectionError("Communication error: %s" % e) from e - - if drain: - self._sock.settimeout(0.5) + def rfcommChannelClosed_(self, channel): + self.closed_event.set() + + + class MacOsRfcommTransport: + """Raw RFCOMM Bluetooth IOBluetooth transport for macOS.""" + + def __init__(self, mac, channel=RFCOMM_CHANNEL, timeout=3.0): + self.mac = mac + self.channel_id = channel + self.timeout = timeout + self.device = None + self.channel = None + self.delegate = None + + def connect(self): + """Open the RFCOMM channel using native IOBluetooth.""" try: - while True: - more = self._sock.recv(4096) - if not more: + self.device = IOBluetoothDevice.deviceWithAddressString_(self.mac) + if not self.device: + raise BmapConnectionError("Device not found: %s" % self.mac) + + self.delegate = MacOsRfcommDelegate.alloc().init() + run_loop = NSRunLoop.currentRunLoop() + + # 1. Query SDP first so macOS registers active Bluetooth services + self.device.performSDPQuery_(None) + start_sdp = time.time() + while time.time() - start_sdp < 1.5: + run_loop.runMode_beforeDate_(NSDefaultRunLoopMode, NSDate.dateWithTimeIntervalSinceNow_(0.05)) + + # 2. Establish baseband connection asynchronously + status = self.device.openConnection_(self.delegate) + if status != 0: + raise BmapConnectionError("Failed to issue openConnection (status %d)" % status) + + # Wait up to 5 seconds for baseband connection to establish + start_bb = time.time() + while time.time() - start_bb < 5.0 and not self.delegate.baseband_event.is_set(): + run_loop.runMode_beforeDate_(NSDefaultRunLoopMode, NSDate.dateWithTimeIntervalSinceNow_(0.05)) + + if not self.delegate.baseband_connected: + raise BmapConnectionError( + "Failed to establish baseband connection (status %d)" % self.delegate.baseband_status + ) + + # 3. Open RFCOMM channel directly on the requested channel ID + status, channel = self.device.openRFCOMMChannelSync_withChannelID_delegate_( + None, self.channel_id, self.delegate + ) + if status != 0: + raise BmapConnectionError("Failed to open RFCOMM channel %d (status %d)" % (self.channel_id, status)) + + # Wait for channel open complete + start_ch = time.time() + while time.time() - start_ch < 3.0 and not self.delegate.channel_event.is_set(): + run_loop.runMode_beforeDate_(NSDefaultRunLoopMode, NSDate.dateWithTimeIntervalSinceNow_(0.05)) + + if not self.delegate.channel_connected: + raise BmapConnectionError("RFCOMM channel open failed (status %d)" % self.delegate.channel_status) + + self.channel = channel + + # 4. Flush any startup handshake/broadcast packets + time.sleep(0.5) + while not self.delegate.received_queue.empty(): + try: + self.delegate.received_queue.get_nowait() + except Exception: + break + except Exception as e: + self.close() + if isinstance(e, BmapConnectionError): + raise + raise BmapConnectionError("Failed to connect to %s: %s" % (self.mac, e)) from e + + def close(self): + """Close the channel.""" + if self.channel: + try: + self.channel.closeChannel() + except Exception: + pass + self.channel = None + self.device = None + self.delegate = None + + def send_recv(self, packet, drain=False): + """Send a BMAP packet and receive the response using NSRunLoop.""" + if not self.channel: + raise BmapConnectionError("Not connected") + + # Clear delegate queue before sending + while not self.delegate.received_queue.empty(): + try: + self.delegate.received_queue.get_nowait() + except Exception: + break + + # Send + # writeSync:length: + status = self.channel.writeSync_length_(packet, len(packet)) + if status != 0: + raise BmapConnectionError("Write failed with status %d" % status) + + # Brief delay for device to process (similar to original transport) + time.sleep(0.2) + + # Wait and receive + data = b"" + start_time = time.time() + run_loop = NSRunLoop.currentRunLoop() + + # Wait for first response + while time.time() - start_time < self.timeout: + run_loop.runMode_beforeDate_(NSDefaultRunLoopMode, NSDate.dateWithTimeIntervalSinceNow_(0.01)) + + if not self.delegate.received_queue.empty(): + data += self.delegate.received_queue.get() + break + + if self.delegate.closed_event.is_set(): + raise BmapConnectionError("Connection closed by peer") + + if not data: + raise BmapTimeoutError("No response from device") + + # Drain if requested + if drain: + drain_timeout = 0.5 + start_drain = time.time() + while time.time() - start_drain < drain_timeout: + run_loop.runMode_beforeDate_(NSDefaultRunLoopMode, NSDate.dateWithTimeIntervalSinceNow_(0.01)) + if not self.delegate.received_queue.empty(): + data += self.delegate.received_queue.get() + start_drain = time.time() # reset drain timeout + if self.delegate.closed_event.is_set(): break - data += more - except (socket.timeout, BlockingIOError): - pass - self._sock.settimeout(self.timeout) - return data + return data + + def __enter__(self): + self.connect() + return self + + def __exit__(self, *exc): + self.close() + +else: + import socket + + class LinuxRfcommTransport: + """Raw RFCOMM Bluetooth socket transport for Linux.""" + + def __init__(self, mac, channel=RFCOMM_CHANNEL, timeout=3.0): + self.mac = mac + self.channel = channel + self.timeout = timeout + self._sock = None + + def connect(self): + """Open the RFCOMM socket to the device.""" + try: + self._sock = socket.socket( + socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM + ) + self._sock.settimeout(self.timeout) + self._sock.connect((self.mac, self.channel)) + except (OSError, socket.error) as e: + self._sock = None + raise BmapConnectionError( + "Failed to connect to %s: %s" % (self.mac, e) + ) from e + + def close(self): + """Close the socket.""" + if self._sock: + try: + self._sock.close() + except OSError: + pass + self._sock = None + + def send_recv(self, packet, drain=False): + if not self._sock: + raise BmapConnectionError("Not connected") + try: + self._sock.send(packet) + time.sleep(0.2) + data = self._sock.recv(4096) + except socket.timeout: + raise BmapTimeoutError("No response from device") + except OSError as e: + raise BmapConnectionError("Communication error: %s" % e) from e + + if drain: + self._sock.settimeout(0.5) + try: + while True: + more = self._sock.recv(4096) + if not more: + break + data += more + except (socket.timeout, BlockingIOError): + pass + self._sock.settimeout(self.timeout) + + return data + + def __enter__(self): + self.connect() + return self + + def __exit__(self, *exc): + self.close() - def __enter__(self): - self.connect() - return self - def __exit__(self, *exc): - self.close() +# Dynamically map RfcommTransport based on OS +RfcommTransport = MacOsRfcommTransport if sys.platform == "darwin" else LinuxRfcommTransport diff --git a/python/tests/test_transport_macos.py b/python/tests/test_transport_macos.py new file mode 100644 index 0000000..5efaa3e --- /dev/null +++ b/python/tests/test_transport_macos.py @@ -0,0 +1,13 @@ +import sys +import pytest +from pybmap.errors import BmapConnectionError +from pybmap.transport import RfcommTransport + +@pytest.mark.skipif(sys.platform != "darwin", reason="Only runs on macOS") +def test_macos_transport_invalid_mac(): + with pytest.raises(BmapConnectionError) as exc_info: + transport = RfcommTransport("00:11:22:33:44:55") + transport.connect() + # Check that a connection error containing a failure explanation was raised + error_msg = str(exc_info.value) + assert any(word in error_msg for word in ["Failed", "Device not found", "connection"]) diff --git a/rust/src/discovery.rs b/rust/src/discovery.rs index 8e59c4d..0e01f45 100644 --- a/rust/src/discovery.rs +++ b/rust/src/discovery.rs @@ -99,3 +99,15 @@ fn detect_device_type(info: &str) -> String { } "qc_ultra2".to_string() } + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(target_os = "macos")] + #[test] + fn test_macos_discovery_returns_none() { + let dev = find_bmap_device(); + assert!(dev.is_none()); + } +} diff --git a/rust/src/transport.rs b/rust/src/transport.rs index 10ed854..c2987f2 100644 --- a/rust/src/transport.rs +++ b/rust/src/transport.rs @@ -196,4 +196,11 @@ mod tests { assert!(parse_mac("AA:BB:CC").is_err()); assert!(parse_mac("GG:HH:II:JJ:KK:LL").is_err()); } + + #[cfg(target_os = "macos")] + #[test] + fn test_macos_transport_fails() { + let res = RfcommTransport::connect("00:11:22:33:44:55", 2); + assert!(res.is_err()); + } }