Skip to content

Commit 4cae06e

Browse files
authored
Merge pull request #8 from Five3Apps/3-service-scanning
Implemented service scanning
2 parents b0c53d0 + 9d49be0 commit 4cae06e

16 files changed

Lines changed: 836 additions & 71 deletions

File tree

Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/project.pbxproj

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@
180180
attributes = {
181181
BuildIndependentTargetsInParallel = 1;
182182
LastSwiftUpdateCheck = 1610;
183-
LastUpgradeCheck = 1610;
183+
LastUpgradeCheck = 1620;
184184
TargetAttributes = {
185185
8B771A932CEC3B9A002F0E31 = {
186186
CreatedOnToolsVersion = 16.1;
@@ -314,6 +314,7 @@
314314
CLANG_WARN_UNREACHABLE_CODE = YES;
315315
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
316316
COPY_PHASE_STRIP = NO;
317+
DEAD_CODE_STRIPPING = YES;
317318
DEBUG_INFORMATION_FORMAT = dwarf;
318319
ENABLE_STRICT_OBJC_MSGSEND = YES;
319320
ENABLE_TESTABILITY = YES;
@@ -375,6 +376,7 @@
375376
CLANG_WARN_UNREACHABLE_CODE = YES;
376377
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
377378
COPY_PHASE_STRIP = NO;
379+
DEAD_CODE_STRIPPING = YES;
378380
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
379381
ENABLE_NS_ASSERTIONS = NO;
380382
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -402,6 +404,7 @@
402404
CODE_SIGN_ENTITLEMENTS = "ReliaBLE Demo/ReliaBLE_Demo.entitlements";
403405
CODE_SIGN_STYLE = Automatic;
404406
CURRENT_PROJECT_VERSION = 1;
407+
DEAD_CODE_STRIPPING = YES;
405408
DEVELOPMENT_ASSET_PATHS = "\"ReliaBLE Demo/Preview Content\"";
406409
DEVELOPMENT_TEAM = AU92CYH9BP;
407410
ENABLE_HARDENED_RUNTIME = YES;
@@ -446,6 +449,7 @@
446449
CODE_SIGN_ENTITLEMENTS = "ReliaBLE Demo/ReliaBLE_Demo.entitlements";
447450
CODE_SIGN_STYLE = Automatic;
448451
CURRENT_PROJECT_VERSION = 1;
452+
DEAD_CODE_STRIPPING = YES;
449453
DEVELOPMENT_ASSET_PATHS = "\"ReliaBLE Demo/Preview Content\"";
450454
DEVELOPMENT_TEAM = AU92CYH9BP;
451455
ENABLE_HARDENED_RUNTIME = YES;
@@ -488,6 +492,7 @@
488492
BUNDLE_LOADER = "$(TEST_HOST)";
489493
CODE_SIGN_STYLE = Automatic;
490494
CURRENT_PROJECT_VERSION = 1;
495+
DEAD_CODE_STRIPPING = YES;
491496
DEVELOPMENT_TEAM = AU92CYH9BP;
492497
GENERATE_INFOPLIST_FILE = YES;
493498
IPHONEOS_DEPLOYMENT_TARGET = 18.1;
@@ -511,6 +516,7 @@
511516
BUNDLE_LOADER = "$(TEST_HOST)";
512517
CODE_SIGN_STYLE = Automatic;
513518
CURRENT_PROJECT_VERSION = 1;
519+
DEAD_CODE_STRIPPING = YES;
514520
DEVELOPMENT_TEAM = AU92CYH9BP;
515521
GENERATE_INFOPLIST_FILE = YES;
516522
IPHONEOS_DEPLOYMENT_TARGET = 18.1;
@@ -533,6 +539,7 @@
533539
buildSettings = {
534540
CODE_SIGN_STYLE = Automatic;
535541
CURRENT_PROJECT_VERSION = 1;
542+
DEAD_CODE_STRIPPING = YES;
536543
DEVELOPMENT_TEAM = AU92CYH9BP;
537544
GENERATE_INFOPLIST_FILE = YES;
538545
IPHONEOS_DEPLOYMENT_TARGET = 18.1;
@@ -555,6 +562,7 @@
555562
buildSettings = {
556563
CODE_SIGN_STYLE = Automatic;
557564
CURRENT_PROJECT_VERSION = 1;
565+
DEAD_CODE_STRIPPING = YES;
558566
DEVELOPMENT_TEAM = AU92CYH9BP;
559567
GENERATE_INFOPLIST_FILE = YES;
560568
IPHONEOS_DEPLOYMENT_TARGET = 18.1;

Demo/ReliaBLE Demo/ReliaBLE Demo.xcodeproj/xcshareddata/xcschemes/ReliaBLE Demo.xcscheme

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?xml version="1.0" encoding="UTF-8"?>
22
<Scheme
3-
LastUpgradeVersion = "1610"
3+
LastUpgradeVersion = "1620"
44
version = "1.7">
55
<BuildAction
66
parallelizeBuildables = "YES"

Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift

Lines changed: 80 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2525
// SOFTWARE.
2626

27+
import Combine
28+
import CoreBluetooth
2729
import SwiftUI
2830
import SwiftData
2931

@@ -33,85 +35,115 @@ struct CentralView: View {
3335
@Environment(\.modelContext) private var modelContext
3436
@Environment(\.bleManager) private var reliaBLE
3537

38+
@Query private var discoveries: [DiscoveryEvent]
3639
@Query private var devices: [Device]
3740

38-
@State private var logsButtonTitle = "Disable Logging"
39-
@State private var currentState: BluetoothState = .unknown
41+
@StateObject private var viewModel = CentralViewModel()
42+
@State private var selectedView: String = "Devices"
4043

4144
var body: some View {
4245
NavigationSplitView {
43-
if let reliaBLE {
44-
Text("ReliaBLE state: \(currentState.description)")
45-
.onReceive(reliaBLE.state.receive(on: DispatchQueue.main)) { newState in
46-
self.currentState = newState
47-
}
48-
}
46+
Text("ReliaBLE state: \(viewModel.currentState.description)")
4947

50-
if case BluetoothState.unauthorized(let authState) = currentState, authState == .notDetermined {
48+
if case BluetoothState.unauthorized(let authState) = viewModel.currentState, authState == .notDetermined {
5149
Button("Authorize Bluetooth") {
52-
try? reliaBLE?.authorizeBluetooth()
50+
viewModel.authorizeBluetooth()
51+
}
52+
.buttonStyle(.bordered)
53+
} else if case BluetoothState.ready = viewModel.currentState {
54+
TextField("Enter service UUIDs (comma-separated)", text: $viewModel.servicesInput)
55+
.textFieldStyle(.roundedBorder)
56+
.padding()
57+
58+
Button("Start Scanning") {
59+
viewModel.startScanning()
60+
}
61+
.buttonStyle(.bordered)
62+
} else if case BluetoothState.scanning = viewModel.currentState {
63+
Button("Stop Scanning") {
64+
viewModel.stopScanning()
5365
}
5466
.buttonStyle(.bordered)
5567
}
5668

57-
Button(logsButtonTitle) {
58-
if logsButtonTitle == "Enable Logging" {
59-
reliaBLE?.loggingService.enabled = true
60-
logsButtonTitle = "Disable Logging"
61-
} else {
62-
reliaBLE?.loggingService.enabled = false
63-
logsButtonTitle = "Enable Logging"
64-
}
69+
Picker("Select View", selection: $selectedView) {
70+
Text("Devices").tag("Devices")
71+
Text("Discoveries").tag("Discoveries")
6572
}
66-
.buttonStyle(.bordered)
73+
.pickerStyle(SegmentedPickerStyle())
74+
.padding()
6775

68-
List {
69-
ForEach(devices) { device in
70-
NavigationLink {
71-
Text("Device seen at \(device.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
72-
} label: {
73-
Text(device.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
74-
}
76+
Group {
77+
if selectedView == "Devices" {
78+
deviceList
79+
} else {
80+
discoveriesList
7581
}
76-
.onDelete(perform: deleteDevices)
7782
}
78-
#if os(macOS)
83+
#if os(macOS)
7984
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
80-
#endif
85+
#endif
8186
.toolbar {
82-
#if os(iOS)
87+
#if os(iOS)
8388
ToolbarItem(placement: .navigationBarTrailing) {
84-
EditButton()
85-
}
86-
#endif
87-
ToolbarItem {
88-
Button(action: addDevice) {
89-
Label("Add Device", systemImage: "plus")
89+
Button("Clear All") {
90+
viewModel.clearAllData()
9091
}
9192
}
93+
#endif
9294
}
9395
} detail: {
94-
Text("Select an device")
96+
Text("Select a device")
97+
}
98+
.onAppear {
99+
viewModel.setDependencies(modelContext: modelContext, reliaBLE: reliaBLE)
100+
}
101+
.onDisappear {
102+
viewModel.cancellables.removeAll()
95103
}
96104
}
97-
98-
private func addDevice() {
99-
withAnimation {
100-
let newDevice = Device(timestamp: Date())
101-
modelContext.insert(newDevice)
105+
106+
private var deviceList: some View {
107+
List {
108+
ForEach(devices) { device in
109+
NavigationLink {
110+
Text("Device Details")
111+
Text("ID: \(device.id)")
112+
Text("Last seen: \(device.lastSeen?.formatted(date: .numeric, time: .standard) ?? "")")
113+
} label: {
114+
Text("\(device.name ?? "Unknown")")
115+
}
116+
}
117+
.onDelete { offsets in
118+
let itemsToDelete = offsets.map { devices[$0] }
119+
viewModel.deleteDevices(itemsToDelete)
120+
}
102121
}
103122
}
104-
105-
private func deleteDevices(offsets: IndexSet) {
106-
withAnimation {
107-
for index in offsets {
108-
modelContext.delete(devices[index])
123+
124+
private var discoveriesList: some View {
125+
List {
126+
ForEach(discoveries) { discoveryEvent in
127+
let timestampString = discoveryEvent.timestamp.formatted(date: .numeric, time: .standard)
128+
let deviceName = discoveryEvent.name
129+
NavigationLink {
130+
Text("Device \(deviceName) seen at \(timestampString): \(discoveryEvent.rssi) dBm")
131+
} label: {
132+
Text("Device \(deviceName) seen at \(timestampString): \(discoveryEvent.rssi) dBm")
133+
}
134+
}
135+
.onDelete { offsets in
136+
let itemsToDelete = offsets.map { discoveries[$0] }
137+
viewModel.deleteDiscoveries(itemsToDelete)
109138
}
110139
}
111140
}
112141
}
113142

114143
#Preview {
115144
CentralView()
116-
.modelContainer(for: Device.self, inMemory: true)
145+
.modelContainer(
146+
for: [Device.self, DiscoveryEvent.self],
147+
inMemory: true
148+
)
117149
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
//
2+
// CentralViewModel.swift
3+
// ReliaBLE Demo
4+
//
5+
// Created by Justin Bergen on 3/8/25.
6+
//
7+
// Copyright (c) 2024 Five3 Apps, LLC <justin@five3apps.com>
8+
//
9+
// Permission is hereby granted, free of charge, to any person obtaining a copy
10+
// of this software and associated documentation files (the "Software"), to deal
11+
// in the Software without restriction, including without limitation the rights
12+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13+
// copies of the Software, and to permit persons to whom the Software is
14+
// furnished to do so, subject to the following conditions:
15+
//
16+
// The above copyright notice and this permission notice shall be included in all
17+
// copies or substantial portions of the Software.
18+
//
19+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25+
// SOFTWARE.
26+
27+
import Combine
28+
import CoreBluetooth
29+
import SwiftData
30+
import SwiftUI
31+
32+
import ReliaBLE
33+
34+
class CentralViewModel: ObservableObject {
35+
@Published var currentState: BluetoothState = .unknown
36+
@Published var servicesInput = ""
37+
38+
var cancellables = Set<AnyCancellable>()
39+
40+
private var modelContext: ModelContext?
41+
private var reliaBLE: ReliaBLEManager?
42+
43+
func setDependencies(modelContext: ModelContext, reliaBLE: ReliaBLEManager) {
44+
self.modelContext = modelContext
45+
self.reliaBLE = reliaBLE
46+
47+
setupSubscriptions()
48+
}
49+
50+
private func setupSubscriptions() {
51+
guard let reliaBLE = reliaBLE, let modelContext = modelContext else { return }
52+
53+
reliaBLE.state
54+
.receive(on: DispatchQueue.main)
55+
.assign(to: \.currentState, on: self)
56+
.store(in: &cancellables)
57+
58+
reliaBLE.peripheralDiscoveries
59+
.receive(on: DispatchQueue.main)
60+
.sink { discoveryEvent in
61+
let event = DiscoveryEvent(
62+
peripheralIdentifier: discoveryEvent.id.uuidString,
63+
name: discoveryEvent.name ?? "Unknown",
64+
rssi: discoveryEvent.rssi,
65+
timestamp: Date()
66+
)
67+
modelContext.insert(event)
68+
try? modelContext.save()
69+
}
70+
.store(in: &cancellables)
71+
72+
reliaBLE.discoveredPeripherals
73+
.receive(on: DispatchQueue.main)
74+
.sink { peripherals in
75+
do {
76+
let allDevices = try modelContext.fetch(FetchDescriptor<Device>())
77+
for peripheral in peripherals {
78+
if let existingDevice = allDevices.first(where: { $0.id == peripheral.id }) {
79+
existingDevice.name = peripheral.name
80+
existingDevice.lastSeen = peripheral.lastSeen
81+
} else {
82+
let newDevice = Device(id: peripheral.id, name: peripheral.name, lastSeen: Date())
83+
modelContext.insert(newDevice)
84+
}
85+
}
86+
try modelContext.save()
87+
} catch {
88+
print("Error fetching devices: \(error)")
89+
}
90+
}
91+
.store(in: &cancellables)
92+
}
93+
94+
func authorizeBluetooth() {
95+
try? reliaBLE?.authorizeBluetooth()
96+
}
97+
98+
func startScanning() {
99+
let services = parseServices(from: servicesInput)
100+
reliaBLE?.startScanning(services: services)
101+
}
102+
103+
func stopScanning() {
104+
reliaBLE?.stopScanning()
105+
}
106+
107+
func clearAllData() {
108+
guard let modelContext = modelContext else { return }
109+
110+
do {
111+
let discoveryDescriptor = FetchDescriptor<DiscoveryEvent>(sortBy: [])
112+
let discoveries = try modelContext.fetch(discoveryDescriptor)
113+
discoveries.forEach { modelContext.delete($0) }
114+
115+
let deviceDescriptor = FetchDescriptor<Device>(sortBy: [])
116+
let devices = try modelContext.fetch(deviceDescriptor)
117+
devices.forEach { modelContext.delete($0) }
118+
119+
try modelContext.save()
120+
} catch {
121+
print("Failed to clear data: \(error)")
122+
}
123+
}
124+
125+
func deleteDiscoveries(_ items: [DiscoveryEvent]) {
126+
items.forEach { modelContext?.delete($0) }
127+
try? modelContext?.save()
128+
}
129+
130+
func deleteDevices(_ items: [Device]) {
131+
items.forEach { modelContext?.delete($0) }
132+
try? modelContext?.save()
133+
}
134+
135+
private func parseServices(from input: String) -> [CBUUID]? {
136+
let components = input.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
137+
let uuids = components.filter { !$0.isEmpty }.map { CBUUID(string: $0) }
138+
139+
return uuids.isEmpty ? nil : uuids
140+
}
141+
}

0 commit comments

Comments
 (0)