From 8387115c28754111edd4be26b5936f085c16b266 Mon Sep 17 00:00:00 2001 From: athexweb3 Date: Mon, 29 Jun 2026 22:04:32 +0600 Subject: [PATCH] feat(examples): unify both roles into one console One BLEConsole drives both roles behind Central, Peripheral, and History tabs, mirroring the SimEnclave example: toast, tristate log with haptics, a SIMBLE_DEMO_SEED hook, brand header, central write and subscribe, and a configurable GATT with a writable characteristic. --- examples/native/Sources/App.swift | 82 ++-- examples/native/Sources/BLEConsole.swift | 400 +++++++++++++++++++ examples/native/Sources/Brand.swift | 89 +++++ examples/native/Sources/CentralView.swift | 186 --------- examples/native/Sources/Components.swift | 91 +++++ examples/native/Sources/PeripheralView.swift | 175 -------- examples/native/Sources/README.md | 16 +- examples/native/Sources/Tabs.swift | 149 +++++++ 8 files changed, 776 insertions(+), 412 deletions(-) create mode 100644 examples/native/Sources/BLEConsole.swift create mode 100644 examples/native/Sources/Brand.swift delete mode 100644 examples/native/Sources/CentralView.swift create mode 100644 examples/native/Sources/Components.swift delete mode 100644 examples/native/Sources/PeripheralView.swift create mode 100644 examples/native/Sources/Tabs.swift diff --git a/examples/native/Sources/App.swift b/examples/native/Sources/App.swift index 3e2c6c6..bfdd925 100644 --- a/examples/native/Sources/App.swift +++ b/examples/native/Sources/App.swift @@ -1,45 +1,57 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2026 Nirapod Labs -import CoreBluetooth import SwiftUI -/// The iOS example: one app with a Central tab and a Peripheral tab. The Central tab scans, lists -/// peripherals, connects, and reads a characteristic; the Peripheral tab publishes a service, -/// advertises, and serves reads, writes, and notifications. In the iOS Simulator armed by the SimBLE -/// helper, both reach the host Mac's radio; on a device they drive the device radio. +/// An interactive Bluetooth Low Energy console. The Central tab scans, connects, reads, writes, +/// and subscribes; the Peripheral tab publishes a configurable GATT service, advertises, and +/// serves it; the History tab is the unified trail. Results land with haptic and toast feedback. +/// Each call is the real native one; the same actions run unchanged on a device, and in the +/// Simulator armed by the SimBLE helper they reach the host Mac's radio. /// /// Launch environment (read at startup, all optional): /// SIMBLE_AUTOSCAN central starts scanning when it reaches poweredOn. /// SIMBLE_AUTOADVERTISE peripheral starts advertising when it reaches poweredOn. -/// SIMBLE_TAB=peripheral open on the Peripheral tab (default Central). +/// SIMBLE_TAB open on "peripheral" or "history" (default central). +/// SIMBLE_DEMO_SEED publish, advertise, and bump the counter at launch for screenshots. @main struct SimBLEExampleApp: App { - @State private var selection: Role = launchTab() - var body: some Scene { - WindowGroup { - TabView(selection: $selection) { - CentralView(autoScan: launchFlag("SIMBLE_AUTOSCAN")) - .tabItem { Label("Central", systemImage: "antenna.radiowaves.left.and.right") } - .tag(Role.central) - PeripheralView(autoAdvertise: launchFlag("SIMBLE_AUTOADVERTISE")) - .tabItem { Label("Peripheral", systemImage: "dot.radiowaves.left.and.right") } - .tag(Role.peripheral) - } - } + WindowGroup { RootView() } } } -/// Which role tab is shown. -enum Role { - case central - case peripheral -} +struct RootView: View { + @State private var console = BLEConsole() + @State private var tab = Self.initialTab + + var body: some View { + @Bindable var console = console + TabView(selection: $tab) { + CentralTab().tag(0) + .tabItem { Label("Central", systemImage: "antenna.radiowaves.left.and.right") } + PeripheralTab().tag(1) + .tabItem { Label("Peripheral", systemImage: "dot.radiowaves.left.and.right") } + HistoryTab().tag(2) + .tabItem { Label("History", systemImage: "clock.arrow.circlepath") } + } + .tint(.blue) + .environment(console) + .toast($console.toast) + .sensoryFeedback(.success, trigger: console.successTick) + .sensoryFeedback(.error, trigger: console.errorTick) + .task { + if ProcessInfo.processInfo.environment["SIMBLE_DEMO_SEED"] == "1" { console.seedDemo() } + } + } -/// The tab to open from SIMBLE_TAB; central unless it is "peripheral". -func launchTab() -> Role { - ProcessInfo.processInfo.environment["SIMBLE_TAB"] == "peripheral" ? .peripheral : .central + private static var initialTab: Int { + switch ProcessInfo.processInfo.environment["SIMBLE_TAB"] { + case "peripheral": 1 + case "history": 2 + default: 0 + } + } } /// Whether launch environment variable `name` is set (present, non-empty). @@ -47,21 +59,3 @@ func launchFlag(_ name: String) -> Bool { guard let value = ProcessInfo.processInfo.environment[name] else { return false } return !value.isEmpty } - -/// One log line, identified for a list. Shared by both roles. -struct LogLine: Identifiable { - let id = UUID() - let text: String -} - -/// A human-readable name for a CoreBluetooth manager state. Shared by both roles. -func describe(_ state: CBManagerState) -> String { - switch state { - case .poweredOn: "Powered on" - case .poweredOff: "Powered off" - case .unauthorized: "Unauthorized" - case .unsupported: "Unsupported" - case .resetting: "Resetting" - default: "Unknown" - } -} diff --git a/examples/native/Sources/BLEConsole.swift b/examples/native/Sources/BLEConsole.swift new file mode 100644 index 0000000..8b168e6 --- /dev/null +++ b/examples/native/Sources/BLEConsole.swift @@ -0,0 +1,400 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Nirapod Labs + +import CoreBluetooth +import Foundation +import Observation +import SwiftUI + +/// A transient toast. +struct Toast: Identifiable, Equatable { + let id = UUID() + enum Kind { case success, error, info } + let kind: Kind + let text: String +} + +/// One history line. `ok` is true for a success, false for a failure, nil for a neutral note. +struct LogLine: Identifiable, Equatable { + let id: Int + let ok: Bool? + let text: String + let time: Date +} + +/// One discovered peripheral, identified for the list. +struct Discovery: Identifiable, Equatable { + let id: UUID + let name: String + let rssi: Int + @ObservationIgnored let peripheral: CBPeripheral + + static func == (lhs: Discovery, rhs: Discovery) -> Bool { + lhs.id == rhs.id && lhs.name == rhs.name && lhs.rssi == rhs.rssi + } +} + +/// A human-readable name for a CoreBluetooth manager state. +func describe(_ state: CBManagerState) -> String { + switch state { + case .poweredOn: "Powered on" + case .poweredOff: "Powered off" + case .unauthorized: "Unauthorized" + case .unsupported: "Unsupported" + case .resetting: "Resetting" + default: "Unknown" + } +} + +/// Validate a service or characteristic UUID string and return its `CBUUID`, nil when malformed. +/// `CBUUID(string:)` raises on bad input, so the 16-, 32-, and 128-bit forms are checked first. +func parseUUID(_ text: String) -> CBUUID? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + let short = "^[0-9A-F]{4}$" + let medium = "^[0-9A-F]{8}$" + let long = "^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$" + for pattern in [short, medium, long] where trimmed.range(of: pattern, options: .regularExpression) != nil { + return CBUUID(string: trimmed) + } + return nil +} + +/// Drives CoreBluetooth on demand from the UI, in both roles, through one model. The central side +/// scans, connects, reads, writes, and subscribes; the peripheral side publishes a configurable +/// GATT service, advertises, and serves reads, writes, and notifications. A unified history trail, +/// a transient toast, and success and failure ticks back the UI. Every call is the real native one; +/// the same actions run unchanged on a device, and in the Simulator armed by the SimBLE helper they +/// reach the host Mac's radio. +@MainActor +@Observable +final class BLEConsole: NSObject { + private(set) var centralState = "Starting" + private(set) var peripheralState = "Starting" + private(set) var centralReady = false + private(set) var peripheralReady = false + private(set) var scanning = false + private(set) var advertising = false + private(set) var found: [Discovery] = [] + private(set) var connectedName: String? + private(set) var subscribed = false + private(set) var counter: UInt8 = 0 + private(set) var subscribers = 0 + private(set) var history: [LogLine] = [] + var toast: Toast? + + /// The published service and characteristic UUIDs, editable on the Peripheral tab. The central + /// reads, writes, and subscribes the characteristic matching the second. + var serviceUUIDText = "F000AA00-0451-4000-B000-000000000000" + var characteristicUUIDText = "F000AA01-0451-4000-B000-000000000000" + + static let localName = "SimBLE Peripheral" + + /// Bumped on every success or failure; a view attaches `.sensoryFeedback` to it. + private(set) var successTick = 0 + private(set) var errorTick = 0 + + @ObservationIgnored private var central: CBCentralManager! + @ObservationIgnored private var peripheral: CBPeripheralManager! + @ObservationIgnored private var connected: CBPeripheral? + @ObservationIgnored private var target: CBCharacteristic? + @ObservationIgnored private var published: CBMutableCharacteristic? + @ObservationIgnored private var logSeq = 0 + + /// Launch flags the mechanism lane sets to drive the guest headlessly: scan on the central's + /// first poweredOn, advertise on the peripheral's first poweredOn. + @ObservationIgnored private let autoScan = launchFlag("SIMBLE_AUTOSCAN") + @ObservationIgnored private let autoAdvertise = launchFlag("SIMBLE_AUTOADVERTISE") + + /// Whether both UUID fields parse, gating the publish and the GATT-dependent central paths. + var gattValid: Bool { parseUUID(serviceUUIDText) != nil && parseUUID(characteristicUUIDText) != nil } + + /// Whether a connected peripheral exposes the configured characteristic. + var hasTarget: Bool { target != nil } + + override init() { + super.init() + central = CBCentralManager(delegate: self, queue: .main) + peripheral = CBPeripheralManager(delegate: self, queue: .main) + } + + // MARK: central + + /// Start or stop scanning for any peripheral. + func toggleScan() { + if scanning { + central.stopScan() + scanning = false + note(nil, "Stopped scanning") + } else if central.state == .poweredOn { + found.removeAll() + central.scanForPeripherals(withServices: nil) + scanning = true + note(nil, "Scanning") + } + } + + /// Connect to a tapped peripheral and discover the configured service. + func connect(_ device: Discovery) { + central.stopScan() + scanning = false + connected = device.peripheral + connectedName = device.name + subscribed = false + target = nil + device.peripheral.delegate = self + central.connect(device.peripheral) + note(nil, "Connecting to \(device.name)") + } + + /// Write one byte to the configured characteristic with response. + func write(_ value: UInt8) { + guard let connected, let target else { return } + connected.writeValue(Data([value]), for: target, type: .withResponse) + note(nil, "Writing \(value)") + } + + /// Subscribe to or unsubscribe from the configured characteristic's notifications. + func toggleSubscribe() { + guard let connected, let target else { return } + connected.setNotifyValue(!subscribed, for: target) + } + + // MARK: peripheral + + /// Start or stop advertising the published service. + func toggleAdvertise() { + guard let serviceUUID = parseUUID(serviceUUIDText) else { + return toast(.error, "Service UUID is malformed") + } + if advertising { + peripheral.stopAdvertising() + advertising = false + note(nil, "Stopped advertising") + } else if peripheral.state == .poweredOn { + peripheral.startAdvertising([ + CBAdvertisementDataLocalNameKey: Self.localName, + CBAdvertisementDataServiceUUIDsKey: [serviceUUID], + ]) + note(nil, "Advertising as \(Self.localName)") + } + } + + /// Bump the served counter and push it to subscribed centrals. + func incrementAndNotify() { + guard let published else { return } + counter &+= 1 + let delivered = peripheral.updateValue(Data([counter]), for: published, onSubscribedCentrals: nil) + note(delivered ? true : nil, "Notified counter \(counter)") + } + + /// Republish the service from the current UUID fields. Removes the prior service first, so a + /// relaunch or an edit never leaves a duplicate primary in the GATT. + func republishService() { + guard peripheral.state == .poweredOn else { return } + guard let serviceUUID = parseUUID(serviceUUIDText), + let characteristicUUID = parseUUID(characteristicUUIDText) + else { + return toast(.error, "GATT UUIDs are malformed") + } + peripheral.removeAllServices() + let characteristic = CBMutableCharacteristic( + type: characteristicUUID, + properties: [.read, .write, .notify], + value: nil, + permissions: [.readable, .writeable]) + let service = CBMutableService(type: serviceUUID, primary: true) + service.characteristics = [characteristic] + published = characteristic + peripheral.add(service) + } + + // MARK: history + + func clearHistory() { history.removeAll() } + + /// Screenshot-only seed, gated behind SIMBLE_DEMO_SEED at launch. Publishes the service, + /// advertises, and bumps the counter twice; the populated screens can then be captured headlessly. + /// Every line is a real local call, not a faked discovery. + func seedDemo() { + republishService() + toggleAdvertise() + incrementAndNotify() + incrementAndNotify() + } + + // MARK: internals + + private func note(_ ok: Bool?, _ text: String) { + logSeq += 1 + history.insert(LogLine(id: logSeq, ok: ok, text: text, time: Date()), at: 0) + if ok == true { successTick += 1 } else if ok == false { errorTick += 1 } + FileHandle.standardError.write(Data("[simble-example] \(text)\n".utf8)) + } + + private func toast(_ kind: Toast.Kind, _ text: String) { + toast = Toast(kind: kind, text: text) + } +} + +// MARK: CBCentralManagerDelegate + +extension BLEConsole: @preconcurrency CBCentralManagerDelegate { + func centralManagerDidUpdateState(_ central: CBCentralManager) { + centralState = describe(central.state) + centralReady = central.state == .poweredOn + note(nil, "Central: \(centralState)") + if centralReady, autoScan, !scanning { toggleScan() } + } + + func centralManager( + _: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi RSSI: NSNumber + ) { + let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String + ?? peripheral.name ?? "Unknown" + let entry = Discovery(id: peripheral.identifier, name: name, rssi: RSSI.intValue, + peripheral: peripheral) + if let index = found.firstIndex(where: { $0.id == peripheral.identifier }) { + found[index] = entry + } else { + found.append(entry) + note(nil, "Found \(name) (\(RSSI) dBm)") + } + } + + func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) { + note(true, "Connected; discovering services") + peripheral.discoverServices(parseUUID(serviceUUIDText).map { [$0] }) + } + + func centralManager(_: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + note(false, "Connect failed: \(error?.localizedDescription ?? "no error")") + connected = nil + connectedName = nil + } + + func centralManager( + _: CBCentralManager, + didDisconnectPeripheral _: CBPeripheral, + error: Error? + ) { + note(error == nil, "Disconnected\(error.map { ": \($0.localizedDescription)" } ?? "")") + connected = nil + connectedName = nil + target = nil + subscribed = false + } +} + +// MARK: CBPeripheralDelegate + +extension BLEConsole: @preconcurrency CBPeripheralDelegate { + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices _: Error?) { + let characteristicUUID = parseUUID(characteristicUUIDText) + for service in peripheral.services ?? [] { + peripheral.discoverCharacteristics(characteristicUUID.map { [$0] }, for: service) + } + } + + func peripheral( + _ peripheral: CBPeripheral, + didDiscoverCharacteristicsFor service: CBService, + error _: Error? + ) { + let characteristicUUID = parseUUID(characteristicUUIDText) + guard let match = (service.characteristics ?? []).first(where: { + characteristicUUID == nil || $0.uuid == characteristicUUID + }) else { return } + target = match + note(true, "Found characteristic \(match.uuid)") + if match.properties.contains(.read) { peripheral.readValue(for: match) } + } + + func peripheral( + _: CBPeripheral, + didUpdateValueFor characteristic: CBCharacteristic, + error: Error? + ) { + if let error { return note(false, "Read failed: \(error.localizedDescription)") } + let bytes = characteristic.value?.count ?? 0 + note(true, "Value \(bytes) B from \(characteristic.uuid)") + } + + func peripheral( + _: CBPeripheral, + didWriteValueFor characteristic: CBCharacteristic, + error: Error? + ) { + note(error == nil, error.map { "Write failed: \($0.localizedDescription)" } + ?? "Wrote \(characteristic.uuid)") + } + + func peripheral( + _: CBPeripheral, + didUpdateNotificationStateFor _: CBCharacteristic, + error: Error? + ) { + if let error { return note(false, "Subscribe failed: \(error.localizedDescription)") } + subscribed.toggle() + note(true, subscribed ? "Subscribed" : "Unsubscribed") + } +} + +// MARK: CBPeripheralManagerDelegate + +extension BLEConsole: @preconcurrency CBPeripheralManagerDelegate { + func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { + peripheralState = describe(peripheral.state) + peripheralReady = peripheral.state == .poweredOn + note(nil, "Peripheral: \(peripheralState)") + if peripheral.state == .poweredOn { + if published == nil { republishService() } + if autoAdvertise, !advertising { toggleAdvertise() } + } + } + + func peripheralManager(_: CBPeripheralManager, didAdd _: CBService, error: Error?) { + note(error == nil, error.map { "Add service failed: \($0.localizedDescription)" } + ?? "Service published") + } + + func peripheralManagerDidStartAdvertising(_: CBPeripheralManager, error: Error?) { + if let error { return note(false, "Advertise failed: \(error.localizedDescription)") } + advertising = true + note(true, "Advertising") + } + + func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveRead request: CBATTRequest) { + request.value = Data([counter]) + peripheral.respond(to: request, withResult: .success) + note(true, "Served read") + } + + func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) { + for request in requests where request.value?.first != nil { + counter = request.value!.first! + } + if let first = requests.first { peripheral.respond(to: first, withResult: .success) } + note(true, "Served write counter \(counter)") + } + + func peripheralManager( + _: CBPeripheralManager, + central _: CBCentral, + didSubscribeTo _: CBCharacteristic + ) { + subscribers += 1 + note(true, "Central subscribed") + } + + func peripheralManager( + _: CBPeripheralManager, + central _: CBCentral, + didUnsubscribeFrom _: CBCharacteristic + ) { + subscribers = max(0, subscribers - 1) + note(nil, "Central unsubscribed") + } +} diff --git a/examples/native/Sources/Brand.swift b/examples/native/Sources/Brand.swift new file mode 100644 index 0000000..6dac88a --- /dev/null +++ b/examples/native/Sources/Brand.swift @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Nirapod Labs + +import SwiftUI + +/// The SimBLE lockup (mark + wordmark) as a text stand-in, used as the navigation title. A wordmark +/// asset under /assets can replace it. +struct SimBLELockup: View { + var size: CGFloat = 17 + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "antenna.radiowaves.left.and.right") + .foregroundStyle(.tint) + Text("SimBLE").font(.system(size: size, weight: .semibold)) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("SimBLE") + } +} + +/// The brand navigation header: the Swift badge on the left, the SimBLE lockup as the title, and +/// an About button on the right that presents the About sheet. Applied to every tab for one +/// consistent bar. +private struct BrandHeader: ViewModifier { + @State private var showAbout = false + + func body(content: Content) -> some View { + content + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Image(systemName: "swift") + .foregroundStyle(Color(red: 0.941, green: 0.318, blue: 0.220)) + .accessibilityLabel("Swift") + } + ToolbarItem(placement: .principal) { SimBLELockup() } + ToolbarItem(placement: .topBarTrailing) { + Button { showAbout = true } label: { Image(systemName: "info.circle") } + .accessibilityLabel("About SimBLE") + } + } + .sheet(isPresented: $showAbout) { AboutSheet() } + } +} + +extension View { + /// Adds the SimBLE brand navigation header (Swift badge, lockup, About button). + func brandHeader() -> some View { modifier(BrandHeader()) } +} + +/// The About panel, presented as a sheet from the header: what the project is, that this is the +/// native example, and the Nirapod Labs credit and license. +struct AboutSheet: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section { + VStack(alignment: .leading, spacing: 8) { + SimBLELockup(size: 26) + Text("Real Bluetooth Low Energy for the Simulator.") + .font(.subheadline).foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + + Section("About") { + Text("SimBLE injects a small interposer into a simulated app, catches its CoreBluetooth calls, and bridges them to your Mac's radio over an authenticated loopback channel. The app scans, connects, advertises, and serves GATT against real hardware.") + } + + Section("This example") { + Text("Native SwiftUI. The Central and Peripheral tabs drive one BLEConsole; a scan, a connect, a write, and a notification all land in the same history.") + } + + Section { + LabeledContent("Built by", value: "Nirapod Labs") + LabeledContent("License", value: "Apache-2.0") + LabeledContent("Status", value: "Early") + } footer: { + Text("© 2026 Nirapod Labs") + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } +} diff --git a/examples/native/Sources/CentralView.swift b/examples/native/Sources/CentralView.swift deleted file mode 100644 index c7ba7e4..0000000 --- a/examples/native/Sources/CentralView.swift +++ /dev/null @@ -1,186 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 Nirapod Labs - -import CoreBluetooth -import SwiftUI - -struct CentralView: View { - /// Start scanning automatically once the central reaches poweredOn. - let autoScan: Bool - - @State private var central = CentralScanner() - - var body: some View { - NavigationStack { - List { - Section(central.state) { - Button(central.scanning ? "Stop" : "Scan") { central.toggleScan() } - .disabled(!central.poweredOn) - } - Section("Peripherals") { - ForEach(central.found) { device in - Button { central.connect(device) } label: { - HStack { - Text(device.name) - Spacer() - Text("\(device.rssi) dBm") - .foregroundStyle(.secondary) - } - } - } - } - if !central.log.isEmpty { - Section("Log") { - ForEach(central.log) { line in - Text(line.text) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - .navigationTitle("SimBLE Central") - } - .onAppear { central.autoScan = autoScan } - } -} - -/// One discovered peripheral, identified for the list. -struct Discovery: Identifiable { - let id: UUID - let name: String - let rssi: Int - let peripheral: CBPeripheral -} - -/// A CoreBluetooth central: scan for any peripheral, list discoveries, and on connect discover -/// services and characteristics and read the first readable characteristic. -@MainActor -@Observable -final class CentralScanner: NSObject, @preconcurrency CBCentralManagerDelegate, - @preconcurrency CBPeripheralDelegate -{ - private(set) var state = "Starting" - private(set) var poweredOn = false - private(set) var scanning = false - private(set) var found: [Discovery] = [] - private(set) var log: [LogLine] = [] - - /// When set, scanning starts on the first poweredOn state. - var autoScan = false - - private var manager: CBCentralManager! - private var connected: CBPeripheral? - - override init() { - super.init() - manager = CBCentralManager(delegate: self, queue: .main) - } - - /// Start or stop scanning for any peripheral. - func toggleScan() { - if scanning { - manager.stopScan() - scanning = false - append("Stopped scanning") - } else if manager.state == .poweredOn { - found.removeAll() - manager.scanForPeripherals(withServices: nil) - scanning = true - append("Scanning") - } - } - - /// Connect to a tapped peripheral. - func connect(_ device: Discovery) { - manager.stopScan() - scanning = false - connected = device.peripheral - device.peripheral.delegate = self - manager.connect(device.peripheral) - append("Connecting to \(device.name)") - } - - private func append(_ text: String) { - log.insert(LogLine(text: text), at: 0) - FileHandle.standardError.write(Data("[simble-example] \(text)\n".utf8)) - } - - // MARK: CBCentralManagerDelegate - - func centralManagerDidUpdateState(_ central: CBCentralManager) { - state = describe(central.state) - poweredOn = central.state == .poweredOn - append("State: \(state)") - if poweredOn, autoScan, !scanning { toggleScan() } - } - - func centralManager( - _: CBCentralManager, - didDiscover peripheral: CBPeripheral, - advertisementData: [String: Any], - rssi RSSI: NSNumber - ) { - let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String - let name = advertisedName ?? peripheral.name ?? "Unknown" - if let index = found.firstIndex(where: { $0.id == peripheral.identifier }) { - found[index] = Discovery( - id: peripheral.identifier, name: name, rssi: RSSI.intValue, peripheral: peripheral - ) - } else { - found.append( - Discovery( - id: peripheral.identifier, name: name, rssi: RSSI.intValue, peripheral: peripheral - ) - ) - append("Found \(name) (\(RSSI) dBm)") - } - } - - func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) { - append("Connected; discovering services") - peripheral.discoverServices(nil) - } - - func centralManager( - _: CBCentralManager, - didFailToConnect peripheral: CBPeripheral, - error: Error? - ) { - append( - "Connect failed for \(peripheral.name ?? "unnamed"): \(error?.localizedDescription ?? "no error")" - ) - connected = nil - } - - // MARK: CBPeripheralDelegate - - func peripheral(_ peripheral: CBPeripheral, didDiscoverServices _: Error?) { - for service in peripheral.services ?? [] { - peripheral.discoverCharacteristics(nil, for: service) - } - } - - func peripheral( - _ peripheral: CBPeripheral, - didDiscoverCharacteristicsFor service: CBService, - error _: Error? - ) { - for characteristic in service.characteristics ?? [] - where characteristic.properties.contains(.read) - { - append("Reading \(characteristic.uuid)") - peripheral.readValue(for: characteristic) - return - } - } - - func peripheral( - _: CBPeripheral, - didUpdateValueFor characteristic: CBCharacteristic, - error _: Error? - ) { - let bytes = characteristic.value?.count ?? 0 - append("Read \(bytes) B from \(characteristic.uuid)") - } -} diff --git a/examples/native/Sources/Components.swift b/examples/native/Sources/Components.swift new file mode 100644 index 0000000..06c0653 --- /dev/null +++ b/examples/native/Sources/Components.swift @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Nirapod Labs + +import SwiftUI + +/// One discovered peripheral as a button row: the name and the last-seen RSSI. +struct DiscoveryRow: View { + let device: Discovery + + var body: some View { + HStack { + Image(systemName: "dot.radiowaves.left.and.right") + .foregroundStyle(.tint) + .frame(width: 24) + Text(device.name) + Spacer() + Text("\(device.rssi) dBm").foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + } +} + +/// One history line: a tinted state icon, the text, and the time. +struct HistoryRow: View { + let line: LogLine + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: icon).foregroundStyle(color) + VStack(alignment: .leading, spacing: 2) { + Text(line.text).font(.callout) + Text(line.time, style: .time).font(.caption2).foregroundStyle(.secondary) + } + } + } + + private var icon: String { + line.ok == true ? "checkmark.circle.fill" : line.ok == false ? "xmark.circle.fill" : "info.circle.fill" + } + private var color: Color { + line.ok == true ? .green : line.ok == false ? .red : .blue + } +} + +/// A transient toast pinned to the top, auto-dismissing. +struct ToastView: View { + let toast: Toast + + var body: some View { + Label(toast.text, systemImage: symbol) + .font(.subheadline.weight(.medium)) + .foregroundStyle(tint) + .padding(.horizontal, 16).padding(.vertical, 11) + .background(.regularMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(tint.opacity(0.25))) + .shadow(color: .black.opacity(0.12), radius: 10, y: 3) + .padding(.top, 8) + } + + private var symbol: String { + switch toast.kind { + case .success: "checkmark.circle.fill" + case .error: "xmark.octagon.fill" + case .info: "info.circle.fill" + } + } + private var tint: Color { + switch toast.kind { + case .success: .green + case .error: .red + case .info: .blue + } + } +} + +extension View { + /// Overlays an auto-dismissing toast bound to `toast`. + func toast(_ toast: Binding) -> some View { + overlay(alignment: .top) { + if let value = toast.wrappedValue { + ToastView(toast: value) + .transition(.move(edge: .top).combined(with: .opacity)) + .task(id: value.id) { + try? await Task.sleep(for: .seconds(1.9)) + withAnimation(.snappy) { toast.wrappedValue = nil } + } + } + } + .animation(.snappy, value: toast.wrappedValue) + } +} diff --git a/examples/native/Sources/PeripheralView.swift b/examples/native/Sources/PeripheralView.swift deleted file mode 100644 index 0aabad9..0000000 --- a/examples/native/Sources/PeripheralView.swift +++ /dev/null @@ -1,175 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 Nirapod Labs - -import CoreBluetooth -import SwiftUI - -struct PeripheralView: View { - /// Start advertising automatically once the peripheral reaches poweredOn. - let autoAdvertise: Bool - - @State private var server = PeripheralServer() - - var body: some View { - NavigationStack { - List { - Section(server.state) { - Button(server.advertising ? "Stop advertising" : "Advertise") { server.toggleAdvertise() } - .disabled(!server.poweredOn) - } - Section("Value") { - LabeledContent("Counter", value: "\(server.counter)") - Button("Increment and notify") { server.increment() } - .disabled(server.subscribers == 0) - } - Section("Status") { - LabeledContent("Subscribers", value: "\(server.subscribers)") - } - if !server.log.isEmpty { - Section("Log") { - ForEach(server.log) { line in - Text(line.text) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - .navigationTitle("SimBLE Peripheral") - } - .onAppear { server.autoAdvertise = autoAdvertise } - } -} - -/// A CoreBluetooth peripheral: publish one service with one readable and notifiable characteristic, -/// advertise a local name, serve reads with the current counter, and accept writes. -@MainActor -@Observable -final class PeripheralServer: NSObject, @preconcurrency CBPeripheralManagerDelegate { - static let serviceUUID = CBUUID(string: "F000AA00-0451-4000-B000-000000000000") - static let characteristicUUID = CBUUID(string: "F000AA01-0451-4000-B000-000000000000") - static let localName = "SimBLE Peripheral" - - private(set) var state = "Starting" - private(set) var poweredOn = false - private(set) var advertising = false - private(set) var counter: UInt8 = 0 - private(set) var subscribers = 0 - private(set) var log: [LogLine] = [] - - /// When set, advertising starts on the first poweredOn state. - var autoAdvertise = false - - private var manager: CBPeripheralManager! - private var characteristic: CBMutableCharacteristic! - - override init() { - super.init() - manager = CBPeripheralManager(delegate: self, queue: .main) - } - - /// Start or stop advertising the service. - func toggleAdvertise() { - if advertising { - manager.stopAdvertising() - advertising = false - append("Stopped advertising") - } else if manager.state == .poweredOn { - manager.startAdvertising([ - CBAdvertisementDataLocalNameKey: Self.localName, - CBAdvertisementDataServiceUUIDsKey: [Self.serviceUUID], - ]) - append("Advertising as \(Self.localName)") - } - } - - /// Bump the counter and push the new value to subscribers. - func increment() { - counter &+= 1 - let value = Data([counter]) - manager.updateValue(value, for: characteristic, onSubscribedCentrals: nil) - append("Notified counter \(counter)") - } - - private func publishService() { - characteristic = CBMutableCharacteristic( - type: Self.characteristicUUID, - properties: [.read, .notify], - value: nil, - permissions: [.readable]) - let service = CBMutableService(type: Self.serviceUUID, primary: true) - service.characteristics = [characteristic] - manager.add(service) - } - - private func append(_ text: String) { - log.insert(LogLine(text: text), at: 0) - FileHandle.standardError.write(Data("[simble-example] \(text)\n".utf8)) - } - - // MARK: CBPeripheralManagerDelegate - - func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { - state = describe(peripheral.state) - poweredOn = peripheral.state == .poweredOn - append("State: \(state)") - if peripheral.state == .poweredOn { - publishService() - if autoAdvertise, !advertising { toggleAdvertise() } - } - } - - func peripheralManager( - _: CBPeripheralManager, - didAdd _: CBService, - error: Error? - ) { - if let error { append("Add service failed: \(error.localizedDescription)") } - else { append("Service published") } - } - - func peripheralManagerDidStartAdvertising(_: CBPeripheralManager, error: Error?) { - if let error { append("Advertise failed: \(error.localizedDescription)") } - else { advertising = true } - } - - func peripheralManager( - _ peripheral: CBPeripheralManager, - didReceiveRead request: CBATTRequest - ) { - request.value = Data([counter]) - peripheral.respond(to: request, withResult: .success) - append("Served read") - } - - func peripheralManager( - _ peripheral: CBPeripheralManager, - didReceiveWrite requests: [CBATTRequest] - ) { - for request in requests { - if let byte = request.value?.first { counter = byte } - } - if let first = requests.first { - peripheral.respond(to: first, withResult: .success) - } - append("Served write counter \(counter)") - } - - func peripheralManager( - _: CBPeripheralManager, - central _: CBCentral, - didSubscribeTo _: CBCharacteristic - ) { - subscribers += 1 - append("Central subscribed") - } - - func peripheralManager( - _: CBPeripheralManager, - central _: CBCentral, - didUnsubscribeFrom _: CBCharacteristic - ) { - subscribers = max(0, subscribers - 1) - append("Central unsubscribed") - } -} diff --git a/examples/native/Sources/README.md b/examples/native/Sources/README.md index 60514c4..f43b6e3 100644 --- a/examples/native/Sources/README.md +++ b/examples/native/Sources/README.md @@ -5,13 +5,15 @@ SPDX-FileCopyrightText: 2026 Nirapod Labs # Sources -The iOS example app with a Central tab and a Peripheral tab. +The iOS example app: a Central tab, a Peripheral tab, and a History tab over one shared console. -- `App.swift`: the `@main` app, a `TabView` over both roles, the launch-environment reading - (`SIMBLE_AUTOSCAN`, `SIMBLE_AUTOADVERTISE`, `SIMBLE_TAB`), and the shared `LogLine` and `describe` - helpers. -- `CentralView.swift`: the Central tab and `CentralScanner` (scan, connect, discover, read). -- `PeripheralView.swift`: the Peripheral tab and `PeripheralServer` (publish, advertise, serve reads, - writes, and notifications). +- `App.swift`: the `@main` app, the `TabView` and root wiring (toast overlay, success and error + haptics), the launch-environment reading (`SIMBLE_AUTOSCAN`, `SIMBLE_AUTOADVERTISE`, `SIMBLE_TAB`, + `SIMBLE_DEMO_SEED`), and the `launchFlag` helper. +- `BLEConsole.swift`: the unified `BLEConsole` that drives both roles, plus the `Toast`, `LogLine`, + and `Discovery` values and the `describe` and `parseUUID` helpers. +- `Components.swift`: the toast view and `View.toast()`, the discovery row, and the history row. +- `Brand.swift`: the SimBLE lockup, the `brandHeader()` navigation modifier, and the About sheet. +- `Tabs.swift`: the Central, Peripheral, and History tabs. Example code, not a CI gate. diff --git a/examples/native/Sources/Tabs.swift b/examples/native/Sources/Tabs.swift new file mode 100644 index 0000000..00352b7 --- /dev/null +++ b/examples/native/Sources/Tabs.swift @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Nirapod Labs + +import SwiftUI + +/// Scan, connect, then read, write, and subscribe the configured characteristic on the connection. +struct CentralTab: View { + @Environment(BLEConsole.self) private var console + @State private var writeValue = 0 + + var body: some View { + NavigationStack { + List { + Section(console.centralState) { + Button(console.scanning ? "Stop" : "Scan") { console.toggleScan() } + .disabled(!console.centralReady) + } + + Section("Peripherals") { + if console.found.isEmpty { + Text("No peripherals yet. Start a scan.").foregroundStyle(.secondary) + } else { + ForEach(console.found) { device in + Button { console.connect(device) } label: { DiscoveryRow(device: device) } + .buttonStyle(.plain) + } + } + } + + if let name = console.connectedName { + Section { + LabeledContent("Connected", value: name) + Stepper("Write byte \(writeValue)", value: $writeValue, in: 0 ... 255) + Button { + console.write(UInt8(writeValue)) + } label: { + Label("Write to characteristic", systemImage: "square.and.pencil") + } + .disabled(!console.hasTarget) + Button { + console.toggleSubscribe() + } label: { + Label(console.subscribed ? "Unsubscribe" : "Subscribe", + systemImage: console.subscribed ? "bell.slash" : "bell") + } + .disabled(!console.hasTarget) + } header: { + Text("Connection") + } footer: { + Text(console.hasTarget + ? "Reads land in History; a write echoes back, and a subscription streams the peripheral's counter." + : "Discovering the configured characteristic.") + } + } + } + .brandHeader() + } + } +} + +/// Publish a configurable GATT service, advertise it, and serve reads, writes, and notifications. +struct PeripheralTab: View { + @Environment(BLEConsole.self) private var console + @FocusState private var editing: Bool + + var body: some View { + @Bindable var console = console + NavigationStack { + List { + Section(console.peripheralState) { + Button(console.advertising ? "Stop advertising" : "Advertise") { console.toggleAdvertise() } + .disabled(!console.peripheralReady) + } + + Section { + TextField("Service UUID", text: $console.serviceUUIDText) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .font(.system(.footnote, design: .monospaced)) + .focused($editing) + TextField("Characteristic UUID", text: $console.characteristicUUIDText) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .font(.system(.footnote, design: .monospaced)) + .focused($editing) + Button { + editing = false + console.republishService() + } label: { + Label("Republish service", systemImage: "arrow.triangle.2.circlepath") + } + .disabled(!console.gattValid) + } header: { + Text("GATT") + } footer: { + Text(console.gattValid + ? "16-, 32-, or 128-bit UUIDs. Republishing removes the prior service first." + : "A UUID is malformed. Use a 16-, 32-, or 128-bit form.") + } + + Section("Value") { + LabeledContent("Counter", value: "\(console.counter)") + Button("Increment and notify") { console.incrementAndNotify() } + .disabled(!console.peripheralReady) + } + + Section("Status") { + LabeledContent("Subscribers", value: "\(console.subscribers)") + } + } + .scrollDismissesKeyboard(.interactively) + .brandHeader() + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { editing = false } + } + } + } + } +} + +/// The full trail of operations, newest first, as a plain grouped list. +struct HistoryTab: View { + @Environment(BLEConsole.self) private var console + + var body: some View { + NavigationStack { + List { + if console.history.isEmpty { + ContentUnavailableView("No activity yet", systemImage: "clock.arrow.circlepath", + description: Text("Run an operation and it shows up here.")) + } else { + ForEach(console.history) { line in HistoryRow(line: line) } + } + } + .brandHeader() + .toolbar { + if !console.history.isEmpty { + ToolbarItem(placement: .topBarTrailing) { + Button("Clear", role: .destructive) { + withAnimation(.snappy) { console.clearHistory() } + } + } + } + } + } + } +}