This repository contains two SwiftUI apps that demonstrate Bluetooth Low Energy (BLE) interaction using CoreBluetooth:
- ScannerQ (iOS): acts as a Central – scans, discovers, connects, and exchanges simple text payloads.
- PeripheralQ (macOS): acts as a Peripheral – advertises and responds to simple text commands.
Developed and tested with: Xcode 26, iOS 26, macOS 26.
| Discovery | Details |
![]() |
![]() |
| PeripheralQ |
![]() |
- Architectural style: MVVM + Repository
- View (SwiftUI):
ScannerQ/UI/*andPeripheralQ/PeripheralView.swift - ViewModel (Combine):
DiscoveryViewModel,DetailsViewModel,InitViewModel - Repository:
ScannerQ/Repositories/BluetoothRepository.swiftencapsulates CoreBluetooth central logic.
- View (SwiftUI):
- Modules
- CommonLibrary: shared types and small helpers (e.g.,
BluetoothDevice, protocol mappers, string utils). - ScannerQ: iOS app (Central role).
- PeripheralQ: macOS app (Peripheral role), see
PeripheralQ/MacHostPeripheral.swift.
- CommonLibrary: shared types and small helpers (e.g.,
- Combine usage
- ViewModels expose
@Publishedstate for device lists, selection, and connection status. - Repository emits discovery/connection updates via Combine publishers that ViewModels subscribe to.
- Subjects used for state streams (Unidirectional streams):
CurrentValueSubjectis used for "stateful" streams (e.g., power state, scanning flag, current connection state, device details, discovered devices). It always holds and replays the latest value to new subscribers, which is ideal for UI that can appear/disappear and still needs the current snapshot immediately on subscribe.PassthroughSubjectis used for transient, event-like data (e.g., inbound text messages). It does not retain the last value, preventing accidental replay of stale, one-off events when a view re-subscribes.
- Why this is good: pairing
CurrentValueSubjectfor durable state withPassthroughSubjectfor ephemeral events provides clear semantics, avoids missing the latest state on new subscriptions, and prevents double-handling of one-off events. - SwiftUI views react to state changes automatically.
- ViewModels expose
- SwiftUI
- Declarative UI with simple, focused screens: discovery list, details/interaction, splash/init.
- Reusable components such as
DeviceRowView.
-
Duplicate advertising names / connection quirk (known issue)
- PeripheralQ currently advertises as both
QDevice1and as the Mac's public name. Both appear in the iOS Discovered devices list. - iOS app fails to connect to
QDevice1, but successfully connects and interacts when the device with the Mac's public name is selected. - Workarounds: select the device entry that shows the Mac's public name.
- PeripheralQ currently advertises as both
-
TextProtocolParser scope and thread-safety
- Parser currently runs on a single queue and keeps internal mutable state; not thread-safe if called concurrently.
- Improvement: Could be split into dedicated sub-parsers (e.g., image/control) and/or made actor/serial-queue based for safety.
-
BluetoothRepository size and separation of concerns
- The repository is intentionally a single-file demo but has grown long (discovery + connection + GATT I/O).
- Improvement: split into focused components (e.g., DiscoveryService, ConnectionManager, IOChannel/TextProtocol) or at least into extensions per area to reduce cognitive load.
-
Permissions and privacy prompts
- BLE requires Bluetooth permission. On first launch, iOS prompts appear; denial blocks functionality.
- Improvement: add a dedicated permissions screen with rationale and links to Settings if permission is denied.
-
Background behavior
- Current sample targets foreground use. Background scanning/advertising is limited and not tuned.
- Improvement: adopt background modes where appropriate and implement reconnection policies with timers.
-
Error handling and retries
- Errors are surfaced minimally. Connection timeouts, characteristic discovery failures, and write errors could be handled more granularly.
- Improvement: structured error types, exponential backoff retries, and user-facing toasts/alerts.
-
Data protocol and reliability
- Simple text messages only.
- Improvement: define a small text/binary protocol with sequence IDs, ACK/NACK, and retry.
-
UI/UX polish
- The UI is minimal and intended for demonstration purposes, though I implemented a name-based filter on the discovery screen and automatic reconnection to the previously connected device.
- Improvements: connection state indicators, and clearer empty/error states.
-
Testing
- No automated tests.
- A few stubs for preview.
- Improvements: unit tests for
TextProtocolParser, repository fakes for CoreBluetooth, and snapshot tests for views.
Prerequisites
- Xcode 26
- A Mac with Bluetooth for the PeripheralQ app
- A real iPhone to run ScannerQ
Recommended order: start the macOS Peripheral first, then the iOS Scanner.
A) Run the macOS Peripheral (PeripheralQ)
- Open the project in Xcode.
- Select the
PeripheralQscheme. - Go to the Signing & Capabilities tab.
- Under Signing, set the Team to your Apple Developer Team.
- Update the Bundle Identifier to a unique identifier (e.g., com.yourcompany.peripheralq).
- Choose "My Mac" as the run destination.
- Run. Click 'Start Hosting' to start advertising. You may see both
QDevice1and your Mac's public name from the iOS app.
B) Run the iOS Scanner (ScannerQ)
- In Xcode, select the
ScannerQscheme. - Go to the Signing & Capabilities tab.
- Under Signing, set the Team to your Apple Developer Team.
- Update the Bundle Identifier to a unique identifier (e.g., com.yourcompany.scannerq).
- Choose an iPhone device (physical device recommended for real BLE discovery).
- Run the app.
- Grant Bluetooth permission when prompted.
- Wait for the device list to populate. If both QDevice1 and your Mac's name appear, select the entry with your Mac's public name to connect successfully.
- The app will navigate to the details/interaction screen, attempt to connect automatically, and exchange handshake hello messages with the simulator.
- You can send sample text messages in both directions.
- Select an image in the simulator (PNG files around 1K in size work best), then press the "Get Image" button on the iPhone.
- Toggle the switch on the iPhone, send it, and observe the remote toggle change state on the Mac.
Troubleshooting
- If no devices appear:
- Ensure the macOS Peripheral app is running and Bluetooth is enabled on both devices.
- Toggle Bluetooth off/on on the Mac and iPhone.
- Kill and relaunch the apps to clear transient CoreBluetooth state.
- If connection to
QDevice1fails:- Select the device entry showing the Mac's public name instead (known limitation above).
- If permissions were denied:
- Go to iOS Settings > Privacy & Security > Bluetooth and enable access for the app.
The diagram below shows the typical end-to-end flow from discovery to text I/O between the iOS central (ScannerQ) and the macOS peripheral (PeripheralQ).
sequenceDiagram
participant iOS as ScannerQ (iOS Central)
participant CB as CoreBluetooth
participant macOS as PeripheralQ (macOS Peripheral)
participant User as iOS User
macOS->>CB: startAdvertising([LocalName, ServiceUUID: NUS])
CB->>macOS: didStartAdvertising(success)
iOS->>CB: init CBCentralManager()
CB-->>iOS: authorization = notDetermined
iOS->>User: Present Bluetooth permission alert
User-->>iOS: Allow / Don't Allow
CB-->>iOS: authorizationDidChange(authorized | denied)
note over iOS: If denied, guide user to Settings > Privacy & Security > Bluetooth
iOS->>CB: scanForPeripherals(allowDuplicates=true)
CB->>iOS: didDiscover(peripheral, advertisementData, RSSI)
iOS->>CB: stopScan()<br/>connect(peripheral)
CB->>iOS: didConnect(peripheral)
iOS->>CB: discoverServices([NUS])
CB->>iOS: didDiscoverServices([NUS])
iOS->>CB: discoverCharacteristics([RX, TX], for: NUS)
CB->>iOS: didDiscoverCharacteristics<br/>(NUS: RX write, TX notify)
iOS->>CB: setNotifyValue(true, for: TX)
CB->>iOS: didUpdateNotificationState(TX, isNotifying=true)
iOS->>CB: writeValue("Hello", to: RX, .withoutResponse)
CB->>macOS: didWrite(to: RX)
macOS->>macOS: app handles text
macOS->>CB: updateValue("Hello ack", on: TX)
CB->>iOS: didUpdateValueFor(TX, data)
iOS->>iOS: Parse and display in Details screen
note over iOS,macOS: On errors/timeouts: show error, optionally retry/backoff
iOS->>CB: cancelPeripheralConnection(peripheral)
CB->>iOS: didDisconnectPeripheral(error?)
Key notes
- RX is the characteristic the central writes to (Peripheral receives data).
- TX is the characteristic the central subscribes to (Peripheral notifies data).
- The sample uses Write Without Response for throughput and TX notifications for inbound data.
- Preferred service is Nordic UART Service (NUS) to keep the demo simple.
These values are defined in CommonLibrary/BluetoothProtocolSpec.swift and used by both apps.
- Service: Nordic UART Service (NUS)
- UUID:
6E400001-B5A3-F393-E0A9-E50E24DCCA9E
- UUID:
- Characteristic: NUS RX (central writes → peripheral receives)
- UUID:
6E400002-B5A3-F393-E0A9-E50E24DCCA9E - Properties (Peripheral side): Write, Write Without Response
- UUID:
- Characteristic: NUS TX (peripheral notifies → central receives)
- UUID:
6E400003-B5A3-F393-E0A9-E50E24DCCA9E - Properties (Peripheral side): Notify
- UUID:
Code references
Gatt.Service.nusServiceUUIDGatt.Characteristic.nusRXUUIDGatt.Characteristic.nusTXUUID
- CommonLibrary
Sources/CommonLibrary/BluetoothDevice.swift: value type representing discovered peripherals.- Protocol parsing and mapping helpers.
- ScannerQ (iOS Central)
Repositories/BluetoothRepository.swift: CoreBluetooth central logic and Combine publishers.UI/Discovery/DiscoveryScreenView.swift,UI/Details/DetailsScreenView.swift,UI/Components/DeviceRowView.swift.UI/*ViewModel.swift: MVVM state and side-effects.
- PeripheralQ (macOS Peripheral)
MacHostPeripheral.swift: advertising/services implementation.PeripheralView.swift: minimal UI surface.


