Health, Motion and Accessories

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

FieldNotes is an app for people who are outdoors, so the hardware around the user is part of the note: the distance hiked to reach a spot, the number of steps, the temperature from a pocket sensor, or an NFC tag on a trail post that opens the right note. This page covers the frameworks that reach that hardware: HealthKit, Core Motion, HomeKit, Core Bluetooth with AccessorySetupKit, and Core NFC. Everything targets the 27 releases (iOS 27, iPadOS 27, macOS 27, watchOS 27 and visionOS 27), built with Xcode 27 and Swift 6.4. FieldNotes keeps a deployment minimum of 26, so APIs that exist only on the 27 releases are marked "new in 27" and guarded with if #available or @available.

These frameworks differ from most on this site in two ways. First, availability is uneven: HealthKit has no data on the Mac, Core NFC works only on iPhone, and AccessorySetupKit exists only on iOS and iPadOS. Every example below is wrapped in #if os(…​) or #if canImport(…​) so the multiplatform FieldNotes target still compiles everywhere. Second, most of them need a physical device, an entitlement or both; each example says so in a plain sentence right after it.

They also share one concurrency pattern: hardware callbacks arrive on a queue the framework chooses. The examples either tell the framework to call back on the main queue, or mark the delegate methods nonisolated and hop to the main actor with a Task, passing only Sendable values across. The background is on Swift Concurrency in Apps and, for the language rules, Actors, Isolation and Sendable.

This page adds four canonical types: FieldTripWorkoutSession (HealthKit), StepCounter (Core Motion), FieldSensorScanner (Core Bluetooth) and NFCNoteTagReader (Core NFC). They use Note and NoteStore from App Architecture the Apple Way.

The Frameworks at a Glance

Framework What it reaches FieldNotes feature

HealthKit

The Health store: workouts, heart rate, distance, energy, steps; live workout sessions

FieldTripWorkoutSession records a hike and attaches its summary to a note

Core Motion

Accelerometer, gyroscope, pedometer, altimeter, headphone motion

StepCounter shows steps since the note was opened; a tilt level for the camera

HomeKit

Home accessories the user set up in the Home app

Not used; mentioned for completeness

Core Bluetooth

Bluetooth Low Energy peripherals

FieldSensorScanner reads a pocket temperature sensor

AccessorySetupKit

A system picker that pairs one accessory privately

Pairing the field sensor without the Bluetooth permission prompt

Core NFC

NFC tags with NDEF messages

NFCNoteTagReader opens the note linked from a tag

Figure: Framework by Platform

A grid with ten framework rows and five platform columns: iOS, iPadOS, macOS, watchOS and visionOS. HealthKit’s data store is available everywhere except the Mac, where it links but has no data. Workout sessions with a live builder run on iOS and iPadOS 26 and later and on watchOS. Workout zones are new in 27, read-only on macOS and visionOS. CMMotionManager is everywhere but macOS; the pedometer is on iPhone and Apple Watch. Headphone motion is on every platform but visionOS. HomeKit is on all but macOS, where only Mac Catalyst has it. Core Bluetooth is on all five. AccessorySetupKit is iOS and iPadOS 18 and later, with a watchOS companion route through iPhone. Core NFC is iPhone only

The detailed API-by-API table is in Platform availability at the end of the page.

HealthKit

HealthKit is the shared, encrypted store behind the Health app. Apps read and write samples (a heart-rate reading, a distance, a workout), each of a specific type, and the user grants access type by type. The store lives on iPhone, iPad (iPadOS 17 and later), Apple Watch and Apple Vision Pro. The framework also links on the Mac, but HKHealthStore.isHealthDataAvailable() returns false there and every call fails, so always check it first.

Enabling HealthKit

Three things are needed before any call works: the HealthKit capability (which adds the entitlement), and two usage descriptions, one for reading and one for writing. Xcode adds the entitlement when you add the capability in Signing & Capabilities:

<!-- FieldNotes/FieldNotes.entitlements (excerpt) -->
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array/>

<!-- FieldNotes/Info.plist (excerpt) -->
<key>NSHealthShareUsageDescription</key>
<string>FieldNotes reads your distance and heart rate to summarize field trips in your notes.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>FieldNotes saves the hikes you record as workouts in Health.</string>

com.apple.developer.healthkit.access lists extra, specially reviewed data types (clinical records, for example); FieldNotes needs none, so the array is empty.

Requesting Authorization

Ask only for the types a feature needs, and ask when the user starts that feature, not at launch. requestAuthorization(toShare:read:) shows the Health permission sheet once per new type; later calls with the same types return immediately. Two privacy rules shape the code:

  • Read access is invisible. The app can’t find out whether the user denied reading a type. A denied type behaves like an empty one: queries return only what the app itself wrote. The user can also grant access to a limited window of recent data only, which looks the same.

  • Share (write) access is visible through authorizationStatus(for:), because the app needs to know whether saving will work.

FieldTripWorkoutSession, introduced below, declares its types and requests access like this:

// FieldNotes/Health/FieldTripWorkoutSession+Authorization.swift
#if os(iOS) || os(watchOS)
import HealthKit

extension FieldTripWorkoutSession {
    static let shareTypes: Set<HKSampleType> = [
        HKObjectType.workoutType(),
        HKQuantityType(.distanceWalkingRunning),
        HKQuantityType(.activeEnergyBurned),
    ]
    static let readTypes: Set<HKObjectType> = [
        HKQuantityType(.distanceWalkingRunning),
        HKQuantityType(.activeEnergyBurned),
        HKQuantityType(.heartRate),
        HKQuantityType(.stepCount),
    ]

    /// Returns false on devices without Health data (for example, an iPad with iPadOS 16 or a Mac).
    func requestAuthorization() async throws -> Bool {
        guard HKHealthStore.isHealthDataAvailable() else { return false }
        try await healthStore.requestAuthorization(toShare: Self.shareTypes, read: Self.readTypes)
        return healthStore.authorizationStatus(for: HKObjectType.workoutType()) == .sharingAuthorized
    }
}
#endif

SwiftUI has a declarative alternative, healthDataAccessRequest(store:shareTypes:readTypes:trigger:completion:) from HealthKitUI, which presents the sheet whenever trigger changes. It suits a settings toggle:

// FieldNotes/Health/HealthAccessRow.swift
#if os(iOS) || os(watchOS)
import SwiftUI
import HealthKit
import HealthKitUI

struct HealthAccessRow: View {
    let healthStore: HKHealthStore
    @State private var requestTrigger = false
    @State private var isRequested = false

    var body: some View {
        Button(isRequested ? "Health Access Requested" : "Connect to Health", systemImage: "heart.text.square") {
            requestTrigger.toggle()
        }
        .disabled(isRequested || !HKHealthStore.isHealthDataAvailable())
        .healthDataAccessRequest(store: healthStore,
                                 shareTypes: FieldTripWorkoutSession.shareTypes,
                                 readTypes: FieldTripWorkoutSession.readTypes,
                                 trigger: requestTrigger) { result in
            Task { @MainActor in isRequested = (try? result.get()) ?? false }
        }
    }
}
#endif

The completion is @Sendable and may run off the main actor, hence the hop before touching @State. The Bool in the result says whether the sheet was shown and answered, not what the user chose.

Reading a Value: Steps Today

Most reads are one-shot queries. The Swift-native query descriptors make them async. A statistics query sums samples over a date range, merging sources (iPhone and Watch) so steps aren’t counted twice:

// FieldNotes/Health/HealthSummaries.swift
#if os(iOS) || os(watchOS) || os(visionOS)
import HealthKit

enum HealthSummaries {
    static func stepsToday(in store: HKHealthStore) async throws -> Int {
        let startOfDay = Calendar.current.startOfDay(for: .now)
        let today = HKQuery.predicateForSamples(withStart: startOfDay, end: .now)
        let descriptor = HKStatisticsQueryDescriptor(
            predicate: .quantitySample(type: HKQuantityType(.stepCount), predicate: today),
            options: .cumulativeSum)
        let statistics = try await descriptor.result(for: store)
        return Int(statistics?.sumQuantity()?.doubleValue(for: .count()) ?? 0)
    }
}
#endif

Workouts on iPhone: FieldTripWorkoutSession

Until iOS 26, a live workout session (the kind that collects sensor data continuously and shows up as an active workout) could run only on Apple Watch; iPhone apps could only save a finished workout afterwards. Since iOS and iPadOS 26, HKWorkoutSession and HKLiveWorkoutBuilder work on iPhone and iPad too. The session manages the workout’s state (prepared, running, paused, stopped, ended). Its builder collects samples from an HKLiveWorkoutDataSource and turns them into an HKWorkout at the end.

FieldTripWorkoutSession records a hike while the user takes notes. It is @Observable and @MainActor so views can read its live numbers; HealthKit calls its delegates on a background queue, so those methods are nonisolated and send plain Double values back to the main actor:

// FieldNotes/Health/FieldTripWorkoutSession.swift
#if os(iOS) || os(watchOS)
import HealthKit
import Observation

@Observable
@MainActor
final class FieldTripWorkoutSession: NSObject {
    enum State: Equatable { case idle, running, paused, ended }

    private(set) var state: State = .idle
    private(set) var distanceMeters: Double = 0
    private(set) var activeKilocalories: Double = 0
    private(set) var heartRate: Double?
    private(set) var heartRateZone: Int?   // set on 27 and later

    @ObservationIgnored let healthStore = HKHealthStore()
    @ObservationIgnored private var session: HKWorkoutSession?
    @ObservationIgnored private var builder: HKLiveWorkoutBuilder?

    var startDate: Date? { builder?.startDate }

    func start() async throws {
        let configuration = HKWorkoutConfiguration()
        configuration.activityType = .hiking
        configuration.locationType = .outdoor

        let session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
        let builder = session.associatedWorkoutBuilder()
        builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore,
                                                     workoutConfiguration: configuration)
        session.delegate = self
        builder.delegate = self
        self.session = session
        self.builder = builder

        let startDate = Date.now
        session.startActivity(with: startDate)
        try await builder.beginCollection(at: startDate)
    }

    func pause() { session?.pause() }
    func resume() { session?.resume() }

    /// Stops collecting, saves the workout to Health and returns it.
    func finish() async throws -> HKWorkout? {
        guard let session, let builder else { return nil }
        let endDate = Date.now
        session.stopActivity(with: endDate)
        try await builder.endCollection(at: endDate)
        let workout = try await builder.finishWorkout()
        session.end()
        return workout
    }
}

extension FieldTripWorkoutSession: HKWorkoutSessionDelegate {
    nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState,
                                    from fromState: HKWorkoutSessionState, date: Date) {
        let newState: State? = switch toState {
        case .running: .running
        case .paused: .paused
        case .ended: .ended
        default: nil
        }
        guard let newState else { return }
        Task { @MainActor in self.state = newState }
    }

    nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: any Error) {
        Task { @MainActor in self.state = .idle }
    }
}

extension FieldTripWorkoutSession: HKLiveWorkoutBuilderDelegate {
    nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}

    // New in 27: called when the user moves into another zone (see Workout Zones below).
    @available(iOS 27, watchOS 27, *)
    nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder,
                                    didUpdateWorkoutZone zoneUpdate: HKLiveWorkoutZoneUpdate) {
        let zoneIndex = zoneUpdate.currentZoneDuration?.zone.index
        Task { @MainActor in self.heartRateZone = zoneIndex }
    }

    nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder,
                                    didCollectDataOf collectedTypes: Set<HKSampleType>) {
        // Read the statistics here, on HealthKit's queue, and send only Doubles to the main actor.
        let distance = workoutBuilder.statistics(for: HKQuantityType(.distanceWalkingRunning))?
            .sumQuantity()?.doubleValue(for: .meter())
        let energy = workoutBuilder.statistics(for: HKQuantityType(.activeEnergyBurned))?
            .sumQuantity()?.doubleValue(for: .kilocalorie())
        let bpm = workoutBuilder.statistics(for: HKQuantityType(.heartRate))?
            .mostRecentQuantity()?.doubleValue(for: .count().unitDivided(by: .minute()))
        Task { @MainActor in
            if let distance { self.distanceMeters = distance }
            if let energy { self.activeKilocalories = energy }
            self.heartRate = bpm
        }
    }
}
#endif

Running it needs the HealthKit capability and a physical iPhone, iPad or Apple Watch; the simulator can’t produce live distance or heart-rate data. Heart rate appears on iPhone only when a paired sensor (an Apple Watch or compatible headphones or chest strap) provides it.

The order of the calls matters. startActivity(with:) starts the session, and beginCollection(at:) starts the builder with the same date. At the end, stopActivity(with:) and endCollection(at:) close the time range, finishWorkout() saves the HKWorkout (with its samples and route) to Health, and end() releases the session. HKWorkoutSession(healthStore:configuration:) is the initializer that exists on both iPhone and Apple Watch.

The view that runs a field trip reads the session and, when the user finishes, writes a note through the store:

// FieldNotes/Health/FieldTripView.swift
#if os(iOS) || os(watchOS)
import SwiftUI
import FieldNotesModel
import FieldNotesServices

struct FieldTripView: View {
    @Environment(NoteStore.self) private var store
    @State private var workout = FieldTripWorkoutSession()
    @State private var errorMessage: String?

    var body: some View {
        Form {
            LabeledContent("Distance",
                           value: Measurement(value: workout.distanceMeters, unit: UnitLength.meters),
                           format: .measurement(width: .abbreviated, usage: .road))
            LabeledContent("Energy", value: workout.activeKilocalories, format: .number.precision(.fractionLength(0)))
            if let heartRate = workout.heartRate {
                LabeledContent("Heart Rate", value: "\(Int(heartRate)) BPM")
            }
            if let zone = workout.heartRateZone {
                LabeledContent("Zone", value: zone + 1, format: .number)
            }
            switch workout.state {
            case .idle, .ended:
                Button("Start Field Trip", systemImage: "figure.hiking") { Task { await start() } }
            case .running:
                Button("Pause", systemImage: "pause.fill") { workout.pause() }
                Button("Finish", systemImage: "stop.fill", role: .destructive) { Task { await finish() } }
            case .paused:
                Button("Resume", systemImage: "play.fill") { workout.resume() }
            }
        }
        .alert("Health Unavailable", isPresented: Binding(get: { errorMessage != nil },
                                                          set: { if !$0 { errorMessage = nil } })) {
            Button("OK") {}
        } message: {
            Text(errorMessage ?? "")
        }
    }

    private func start() async {
        do {
            guard try await workout.requestAuthorization() else {
                errorMessage = "Allow FieldNotes to save workouts in Settings > Health."
                return
            }
            try await workout.start()
        } catch {
            errorMessage = error.localizedDescription
        }
    }

    private func finish() async {
        guard let hike = try? await workout.finish() else { return }
        let kilometers = (hike.totalDistance?.doubleValue(for: .meterUnit(with: .kilo)) ?? 0)
        store.add(Note(title: "Field trip",
                       body: "Hiked \(kilometers.formatted(.number.precision(.fractionLength(1)))) km " +
                             "in \(Duration.seconds(hike.duration).formatted(.time(pattern: .hourMinute))).",
                       tags: ["field-trip"]))
    }
}

#Preview {
    FieldTripView()
        .environment(NoteStore())
}
#endif

On iPhone, Apple’s sample also shows the running workout in a Live Activity and lets the user pause it from the Lock Screen with App Intents. FieldNotes already has FieldTripLiveActivity for its field trips, see Notifications, APNs and Live Activities, and its intents are on App Intents and System Integration. The watch side (mirroring a session between Apple Watch and iPhone, startMirroringToCompanionDevice, the Always On display and complications) is on watchOS: App Structure, Connectivity, Workouts and the Smart Stack.

Workout Zones (New in 27)

Heart-rate and cycling-power zones divide effort into bands. In the 27 releases HealthKit exposes them: the user’s preferred zones from Health settings with preferredWorkoutZoneConfiguration(for:), live zone changes through a new HKLiveWorkoutBuilderDelegate method, and time-in-zone for finished workouts with HKWorkout.zoneGroup(for:), which returns an HKWorkoutZoneGroup. An app can also override the zones for one workout with setCustomZoneConfiguration(_:for:).

All of it is new in iOS, iPadOS, macOS, watchOS and visionOS 27; the live delegate method exists on iOS, iPadOS and watchOS 27 only. With a deployment minimum of 26 the code is marked @available. The live callback was already shown in the delegate extension above; the rest reads zones before and after the hike:

// FieldNotes/Health/FieldTripWorkoutSession+Zones.swift
#if os(iOS) || os(watchOS)
import HealthKit

// The live zone callback, workoutBuilder(_:didUpdateWorkoutZone:), is in the HKLiveWorkoutBuilderDelegate
// extension above, next to the other delegate methods, so the Objective-C optional requirement is matched.
extension FieldTripWorkoutSession {
    /// The user's heart-rate zones from Health settings, lowest first.
    @available(iOS 27, watchOS 27, *)
    func preferredHeartRateZones() async throws -> [HKWorkoutZone] {
        let configuration = try await healthStore.preferredWorkoutZoneConfiguration(for: HKQuantityType(.heartRate))
        return configuration?.zones ?? []
    }

    /// Minutes spent in each heart-rate zone of a finished hike, for the note's summary.
    @available(iOS 27, watchOS 27, *)
    static func minutesInZones(of workout: HKWorkout) -> [(zone: Int, minutes: Double)] {
        guard let group = workout.zoneGroup(for: HKQuantityType(.heartRate)) else { return [] }
        return group.zoneDurations.map { (zone: $0.zone.index + 1, minutes: $0.duration / 60) }
    }
}
#endif

finish() can then append a line such as "Zone 2: 34 min, Zone 3: 12 min" to the note when #available(iOS 27, watchOS 27, *) is true. On 26 the view simply never shows a zone, because heartRateZone stays nil.

Core Motion

Core Motion reads the motion sensors: accelerometer, gyroscope, magnetometer, barometer, and the motion coprocessor that counts steps. Anything that reads motion or fitness activity needs NSMotionUsageDescription in the Info properties, and the user can turn access off in Settings > Privacy & Security > Motion & Fitness.

StepCounter with CMPedometer

The pedometer counts steps, distance and floors in the motion coprocessor, even while the app isn’t running, and keeps about a week of history. StepCounter shows the steps since the user opened a note. The update handler runs on a private queue, so the closure is explicitly @Sendable and hands plain values to the main actor. Without @Sendable, a closure written in a main-actor type would inherit main-actor isolation, and Swift 6 would stop the app with an isolation error when Core Motion calls it from its own queue:

// FieldNotes/Motion/StepCounter.swift
#if os(iOS) || os(watchOS)
import CoreMotion
import Observation

@Observable
@MainActor
final class StepCounter {
    private(set) var steps = 0
    private(set) var meters: Double?
    let isAvailable = CMPedometer.isStepCountingAvailable()
    @ObservationIgnored private let pedometer = CMPedometer()

    func start(from startDate: Date) {
        guard isAvailable, CMPedometer.authorizationStatus() != .denied else { return }
        pedometer.startUpdates(from: startDate) { @Sendable data, error in
            guard let data, error == nil else { return }
            let steps = data.numberOfSteps.intValue
            let meters = data.distance?.doubleValue
            Task { @MainActor in
                self.steps = steps
                self.meters = meters
            }
        }
    }

    func stop() {
        pedometer.stopUpdates()
    }

    /// Steps in a past range, from the coprocessor's history (about seven days).
    func steps(from start: Date, to end: Date) async throws -> Int {
        try await withCheckedThrowingContinuation { continuation in
            pedometer.queryPedometerData(from: start, to: end) { @Sendable data, error in
                if let error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume(returning: data?.numberOfSteps.intValue ?? 0)
                }
            }
        }
    }
}
#endif

isStepCountingAvailable() must be checked because not every device has the hardware; most iPads return false. The class stays alive while the pedometer holds its handler, so stop() is called when the note closes. In the note editor it looks like this:

// FieldNotes/Views/NoteEditor.swift (excerpt)
#if os(iOS) || os(watchOS)
@State private var stepCounter = StepCounter()

var stepsRow: some View {
    LabeledContent("Steps Since Opening", value: stepCounter.steps, format: .number)
        .opacity(stepCounter.isAvailable ? 1 : 0)
        .onAppear { stepCounter.start(from: .now) }
        .onDisappear { stepCounter.stop() }
}
#endif

The step count runs on a physical iPhone or Apple Watch only; the simulator has no pedometer. It needs NSMotionUsageDescription and no capability.

Device Motion with CMMotionManager

CMMotionManager streams raw and fused sensor data. Device motion is the fused one: attitude (roll, pitch, yaw), gravity, user acceleration and rotation rate, with the sensor bias already removed. FieldNotes uses it for a small spirit level in the camera screen, so photos of a notebook page are taken flat. Create one manager per app; several instances slow down updates.

// FieldNotes/Motion/DeviceLevel.swift
#if os(iOS) || os(watchOS) || os(visionOS)
import CoreMotion
import Observation

@Observable
@MainActor
final class DeviceLevel {
    static let shared = DeviceLevel()
    private(set) var pitchDegrees: Double = 0
    private(set) var rollDegrees: Double = 0
    @ObservationIgnored private let manager = CMMotionManager()

    var isLevel: Bool { abs(pitchDegrees) < 2 && abs(rollDegrees) < 2 }

    func start() {
        guard manager.isDeviceMotionAvailable, !manager.isDeviceMotionActive else { return }
        manager.deviceMotionUpdateInterval = 1.0 / 30.0
        // Deliver on the main queue, which is the main actor, so the handler can update state directly.
        manager.startDeviceMotionUpdates(to: .main) { motion, _ in
            guard let attitude = motion?.attitude else { return }
            self.pitchDegrees = attitude.pitch * 180 / .pi
            self.rollDegrees = attitude.roll * 180 / .pi
        }
    }

    func stop() {
        manager.stopDeviceMotionUpdates()
    }
}
#endif

Here the opposite choice from StepCounter is right: the updates are delivered on OperationQueue.main, so the closure’s inherited main-actor isolation is correct and no hop is needed. Stop updates as soon as the screen closes; the sensors cost battery. CMMotionManager isn’t available on macOS.

Headphone Motion

AirPods and Beats headphones with spatial audio report the wearer’s head orientation through CMHeadphoneMotionManager, on iOS, iPadOS, macOS (14 and later) and watchOS. FieldNotes uses it for a hands-free gesture: while a voice memo plays, a nod bookmarks the current position. The same NSMotionUsageDescription covers it:

// FieldNotes/Motion/HeadphoneNodDetector.swift
#if os(iOS) || os(macOS) || os(watchOS)
import CoreMotion
import Observation

@Observable
@MainActor
final class HeadphoneNodDetector {
    private(set) var nodCount = 0
    @ObservationIgnored private let manager = CMHeadphoneMotionManager()
    @ObservationIgnored private var wasLookingDown = false

    func start() {
        guard manager.isDeviceMotionAvailable else { return }
        manager.startDeviceMotionUpdates(to: .main) { motion, _ in
            guard let pitch = motion?.attitude.pitch else { return }
            let isLookingDown = pitch < -0.35          // about 20 degrees down
            if self.wasLookingDown && !isLookingDown { self.nodCount += 1 }
            self.wasLookingDown = isLookingDown
        }
    }

    func stop() {
        manager.stopDeviceMotionUpdates()
    }
}
#endif

It needs compatible headphones connected to a physical device. On watchOS, headphone motion is delivered to the watch only when the headphones are connected to it.

HomeKit, Briefly

HomeKit reads and controls the accessories a user set up in the Home app (lights, locks, thermostats, sensors), organized into homes, rooms and scenes through HMHomeManager. It needs the HomeKit capability and NSHomeKitUsageDescription, and it is available on iOS, iPadOS, watchOS, tvOS and visionOS, and on the Mac only through Mac Catalyst. FieldNotes doesn’t use it; a note-taking app has no business in the user’s home. For reference, reading the homes looks like this:

// Example only; not part of FieldNotes.
#if canImport(HomeKit) && !os(macOS)
import HomeKit

@MainActor
final class HomeNamesReader: NSObject {
    private let manager = HMHomeManager()
    private(set) var homeNames: [String] = []

    override init() {
        super.init()
        manager.delegate = self   // the first delegate call arrives once the homes have loaded
    }
}

// HomeKit calls its delegate on the main queue, so the conformance can be main-actor isolated.
extension HomeNamesReader: @MainActor HMHomeManagerDelegate {
    func homeManagerDidUpdateHomes(_ manager: HMHomeManager) {
        homeNames = manager.homes.map(\.name)
    }
}
#endif

It needs the HomeKit capability and NSHomeKitUsageDescription; the first access shows the Home data prompt.

Core Bluetooth: FieldSensorScanner

Core Bluetooth talks to Bluetooth Low Energy devices. The app usually plays the central role: a CBCentralManager scans for peripherals that advertise services, connects to one, discovers its characteristics and reads or subscribes to their values. Every identifier is a CBUUID. Standard services have short 16-bit UUIDs; the Bluetooth SIG’s Environmental Sensing service is 181A and its Temperature characteristic is 2A6E, a signed 16-bit value in hundredths of a degree Celsius.

The app must declare NSBluetoothAlwaysUsageDescription, and the first use of a CBCentralManager shows the Bluetooth prompt. On macOS, sandboxed apps also need the Bluetooth App Sandbox entitlement.

FieldSensorScanner creates its manager with a nil queue, which means Core Bluetooth calls every delegate method on the main queue. That lets the conformances be isolated conformances, @MainActor CBCentralManagerDelegate: the delegate methods run on the main actor and can update observable state directly, and the Swift runtime checks that they really are called there.

// FieldNotes/Accessories/FieldSensorScanner.swift
import CoreBluetooth
import Observation

struct DiscoveredSensor: Identifiable, Hashable {
    let id: UUID          // CBPeripheral.identifier: stable for this device on this phone
    var name: String
    var rssi: Int
}

@Observable
@MainActor
final class FieldSensorScanner: NSObject {
    static let environmentalSensing = CBUUID(string: "181A")
    static let temperature = CBUUID(string: "2A6E")

    private(set) var bluetoothState: CBManagerState = .unknown
    private(set) var sensors: [DiscoveredSensor] = []
    private(set) var temperature: Measurement<UnitTemperature>?

    @ObservationIgnored private var central: CBCentralManager!
    @ObservationIgnored private var peripherals: [UUID: CBPeripheral] = [:]

    override init() {
        super.init()
        central = CBCentralManager(delegate: self, queue: nil)   // nil: callbacks on the main queue
    }

    func startScanning() {
        guard bluetoothState == .poweredOn else { return }
        central.scanForPeripherals(withServices: [Self.environmentalSensing])
    }

    func stopScanning() {
        central.stopScan()
    }

    func connect(to sensorID: UUID) {
        let peripheral = peripherals[sensorID]
            ?? central.retrievePeripherals(withIdentifiers: [sensorID]).first   // a sensor paired earlier
        guard let peripheral else { return }
        peripherals[sensorID] = peripheral
        stopScanning()
        central.connect(peripheral)
    }
}

extension FieldSensorScanner: @MainActor CBCentralManagerDelegate {
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        bluetoothState = central.state
    }

    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
                        advertisementData: [String: Any], rssi RSSI: NSNumber) {
        peripherals[peripheral.identifier] = peripheral
        let sensor = DiscoveredSensor(id: peripheral.identifier,
                                      name: peripheral.name ?? "Field Sensor",
                                      rssi: RSSI.intValue)
        if let index = sensors.firstIndex(where: { $0.id == sensor.id }) {
            sensors[index] = sensor
        } else {
            sensors.append(sensor)
        }
    }

    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        peripheral.delegate = self
        peripheral.discoverServices([Self.environmentalSensing])
    }
}

extension FieldSensorScanner: @MainActor CBPeripheralDelegate {
    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: (any Error)?) {
        for service in peripheral.services ?? [] where service.uuid == Self.environmentalSensing {
            peripheral.discoverCharacteristics([Self.temperature], for: service)
        }
    }

    func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService,
                    error: (any Error)?) {
        for characteristic in service.characteristics ?? [] where characteristic.uuid == Self.temperature {
            peripheral.readValue(for: characteristic)
            peripheral.setNotifyValue(true, for: characteristic)   // keep receiving new readings
        }
    }

    func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic,
                    error: (any Error)?) {
        guard let data = characteristic.value, data.count >= 2 else { return }
        let raw = data.withUnsafeBytes { Int16(littleEndian: $0.loadUnaligned(as: Int16.self)) }
        temperature = Measurement(value: Double(raw) / 100, unit: .celsius)
    }
}

The sensor list uses the peripheral identifier as its stable identity, and the chosen reading is written into the note through the store:

// FieldNotes/Accessories/FieldSensorView.swift
import SwiftUI
import FieldNotesModel
import FieldNotesServices

struct FieldSensorView: View {
    @Environment(NoteStore.self) private var store
    @State private var scanner = FieldSensorScanner()
    let noteID: Note.ID

    var body: some View {
        List {
            Section("Nearby Sensors") {
                ForEach(scanner.sensors) { sensor in
                    Button {
                        scanner.connect(to: sensor.id)
                    } label: {
                        LabeledContent(sensor.name, value: "\(sensor.rssi) dBm")
                    }
                }
            }
            if let temperature = scanner.temperature {
                Section("Reading") {
                    Text(temperature, format: .measurement(width: .abbreviated))
                    Button("Add to Note", systemImage: "text.badge.plus") { addReading(temperature) }
                }
            }
        }
        .onChange(of: scanner.bluetoothState) { _, state in
            if state == .poweredOn { scanner.startScanning() }
        }
        .onDisappear { scanner.stopScanning() }
    }

    private func addReading(_ temperature: Measurement<UnitTemperature>) {
        guard let index = store.notes.firstIndex(where: { $0.id == noteID }) else { return }
        store.notes[index].body += "\nTemperature: \(temperature.formatted(.measurement(width: .abbreviated)))"
    }
}

#Preview {
    let note = Note(title: "Ridge weather")
    FieldSensorView(noteID: note.id)
        .environment(NoteStore(notes: [note]))
}

Scanning needs a physical device and a real Bluetooth LE peripheral; the simulator has no Bluetooth. Core Bluetooth is available on all five platforms, with limits: on watchOS and visionOS, scanning and connections are restricted in the background, and on iOS, background scanning requires the Uses Bluetooth LE accessories background mode and a service filter.

AccessorySetupKit: Private Pairing

The Bluetooth prompt is broad: it lets the app see every Bluetooth device around the user. AccessorySetupKit (iOS and iPadOS 18 and later) replaces it with a system accessory picker. The app describes the accessories it supports, the system finds matching ones and shows them with the app’s name and product image, and the user taps one. The app then gets access to that accessory only, with no Bluetooth permission prompt. Paired accessories appear in Settings > Privacy & Security > Accessories, where the user can remove them.

The app declares its accessories in the Info properties. The NSAccessorySetupKitSupports key lists the transports; Bluetooth accessories also need the services (or names or company identifiers) the picker may match. Using an identifier that isn’t declared here crashes the app:

<!-- FieldNotes/Info.plist (excerpt) -->
<key>NSAccessorySetupKitSupports</key>
<array>
    <string>Bluetooth</string>
</array>
<key>NSAccessorySetupBluetoothServices</key>
<array>
    <string>181A</string>
</array>

FieldSensorPairing activates an ASAccessorySession, shows the picker for one display item, and hands the chosen accessory’s Bluetooth identifier to FieldSensorScanner, which connects with retrievePeripherals(withIdentifiers:) instead of scanning:

// FieldNotes/Accessories/FieldSensorPairing.swift
#if os(iOS) && canImport(AccessorySetupKit)
import AccessorySetupKit
import CoreBluetooth
import UIKit

@MainActor
final class FieldSensorPairing {
    private let session = ASAccessorySession()
    private let scanner: FieldSensorScanner

    init(scanner: FieldSensorScanner) {
        self.scanner = scanner
        session.activate(on: .main) { [weak self] event in
            guard let self else { return }
            switch event.eventType {
            case .activated:
                // Accessories the user already paired with FieldNotes.
                self.session.accessories.compactMap(\.bluetoothIdentifier).forEach(self.scanner.connect(to:))
            case .accessoryAdded:
                if let id = event.accessory?.bluetoothIdentifier { self.scanner.connect(to: id) }
            default:
                break
            }
        }
    }

    func showPicker() async throws {
        let descriptor = ASDiscoveryDescriptor()
        descriptor.bluetoothServiceUUID = FieldSensorScanner.environmentalSensing
        let image = UIImage(named: "FieldSensor") ?? UIImage(systemName: "sensor")!
        let item = ASPickerDisplayItem(name: "FieldNotes Sensor", productImage: image, descriptor: descriptor)
        try await session.showPicker(for: [item])
    }
}
#endif

The session’s events arrive on the queue passed to activate(on:eventHandler:), here the main queue, which is why the closure can use the main-actor scanner. AccessorySetupKit also pairs Wi-Fi accessories (by SSID) and Wi-Fi Aware devices. Since watchOS 26, a companion watch app can use Core Bluetooth to talk to an accessory that the iPhone app set up with AccessorySetupKit. It needs a physical iPhone or iPad with iOS or iPadOS 18 or later and a real accessory.

Core NFC: NFCNoteTagReader

Core NFC reads NFC tags on iPhone (iPhone 7 and later); iPad, Mac, Apple Watch and Apple Vision Pro can’t read tags from apps. FieldNotes puts cheap NDEF stickers on trail posts or sample jars. Each holds a link to a note, and scanning it opens the note. The tag’s content is an NDEF message with one URI record:

// FieldNotes/NFC/NoteTagMessage.swift
#if os(iOS) && canImport(CoreNFC)
import CoreNFC
import FieldNotesModel

enum NoteTagMessage {
    /// The universal link a tag holds, so iPhone opens the note even without the app scanning.
    static func message(for noteID: Note.ID) -> NFCNDEFMessage? {
        let url = URL(string: "https://fieldnotes.example.com/note/\(noteID.uuidString)")!
        guard let record = NFCNDEFPayload.wellKnownTypeURIPayload(url: url) else { return nil }
        return NFCNDEFMessage(records: [record])
    }
}
#endif

Writing the message to a sticker uses the same reader session with connect(to:) on a detected tag; Apple’s Creating NFC Tags from Your iPhone sample shows the full flow.

Because the record is a universal link, iPhone can read the tag in the background, with the screen on and no app running, and offers a notification that opens FieldNotes at that note through onOpenURL, as set up on Navigation and Presentation. The associated domain is configured on App Intents and System Integration.

Inside the app, NFCNoteTagReader starts an NFCNDEFReaderSession, which shows the system scanning sheet, and returns the first FieldNotes link it reads. The delegate runs on a Core NFC queue, and the continuation can be touched from both that queue and the caller, so it lives in a Mutex from the Synchronization module:

// FieldNotes/NFC/NFCNoteTagReader.swift
#if os(iOS) && canImport(CoreNFC)
import CoreNFC
import Synchronization

nonisolated final class NFCNoteTagReader: NSObject, NFCNDEFReaderSessionDelegate, Sendable {
    enum ReaderError: Error { case readingUnavailable, noNoteLink }

    private let pending = Mutex<CheckedContinuation<URL, any Error>?>(nil)
    private let activeSession = Mutex<NFCNDEFReaderSession?>(nil)

    /// Shows the scanning sheet and returns the note link on the first tag read.
    func scanNoteLink() async throws -> URL {
        guard NFCNDEFReaderSession.readingAvailable else { throw ReaderError.readingUnavailable }
        return try await withCheckedThrowingContinuation { continuation in
            pending.withLock { $0 = continuation }
            let session = NFCNDEFReaderSession(delegate: self, queue: nil, invalidateAfterFirstRead: true)
            session.alertMessage = "Hold your iPhone near a FieldNotes tag."
            session.begin()
            activeSession.withLock { $0 = session }   // keep the session alive while it scans
        }
    }

    private func finish(_ result: Result<URL, any Error>) {
        pending.withLock { $0.take() }?.resume(with: result)   // resumes at most once
        activeSession.withLock { $0 = nil }
    }

    func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
        let link = messages.flatMap(\.records)
            .compactMap { $0.wellKnownTypeURIPayload() }
            .first { $0.scheme == "fieldnotes" || $0.host() == "fieldnotes.example.com" }
        finish(link.map { .success($0) } ?? .failure(ReaderError.noNoteLink))
    }

    func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: any Error) {
        // Also called after a successful first read; finish(_:) ignores it then.
        finish(.failure(error))
    }
}
#endif

The note list offers a Scan Tag button that opens whatever note the tag names, reusing the app’s URL routing:

// FieldNotes/Views/NoteList.swift (excerpt)
#if os(iOS) && canImport(CoreNFC)
@Environment(\.openURL) private var openURL
@State private var tagReader = NFCNoteTagReader()

Button("Scan Tag", systemImage: "wave.3.right") {
    Task {
        if let link = try? await tagReader.scanNoteLink() {
            openURL(link)   // handled by onOpenURL, like any fieldnotes:// or universal link
        }
    }
}
#endif

Core NFC needs the Near Field Communication Tag Reading capability, which Xcode adds as the com.apple.developer.nfc.readersession.formats entitlement, an NFCReaderUsageDescription in the Info properties, a paid Apple Developer Program membership (personal teams can’t use the capability) and a physical iPhone:

<!-- FieldNotes/FieldNotes.entitlements (excerpt) -->
<key>com.apple.developer.nfc.readersession.formats</key>
<array>
    <string>TAG</string>
</array>

<!-- FieldNotes/Info.plist (excerpt) -->
<key>NFCReaderUsageDescription</key>
<string>FieldNotes reads NFC tags that link to your notes.</string>

Tags that aren’t NDEF-formatted (ISO 7816, ISO 15693, FeliCa or MIFARE) need Core NFC’s lower-level tag reader session instead; FieldNotes only uses NDEF stickers.

Requirements and Platform Availability

Each feature on this page needs something beyond the SDK:

Feature Capability, entitlement and Info keys Device

FieldTripWorkoutSession

HealthKit capability; NSHealthShareUsageDescription, NSHealthUpdateUsageDescription

Physical iPhone, iPad or Apple Watch

HealthSummaries, HealthAccessRow

HealthKit capability; the same two keys

Simulator (with sample data) or device

StepCounter, DeviceLevel, HeadphoneNodDetector

NSMotionUsageDescription

Physical device; headphones for head motion

HomeKit

HomeKit capability; NSHomeKitUsageDescription

Simulator with the HomeKit Accessory Simulator, or device

FieldSensorScanner

NSBluetoothAlwaysUsageDescription; Bluetooth sandbox entitlement on macOS

Physical device and a Bluetooth LE sensor

FieldSensorPairing

NSAccessorySetupKitSupports, NSAccessorySetupBluetoothServices

Physical iPhone or iPad with 18 or later

NFCNoteTagReader

NFC Tag Reading capability (paid membership); NFCReaderUsageDescription

Physical iPhone

Platform availability

API or feature iOS iPadOS macOS watchOS visionOS

HealthKit store (HKHealthStore)

Yes

Yes (17)

Yes (links; no Health data)

Yes

Yes

healthDataAccessRequest (HealthKitUI)

Yes

Yes

Not available

Yes

Yes

HKStatisticsQueryDescriptor

Yes

Yes

Yes (links; no Health data)

Yes

Yes

HKWorkoutSession(healthStore:configuration:)

New in 26

New in 26

Not available

Yes

Not available

HKLiveWorkoutBuilder, HKLiveWorkoutDataSource

New in 26

New in 26

Not available

Yes

Not available

HKWorkoutZoneGroup, preferredWorkoutZoneConfiguration(for:)

New in 27

New in 27

New in 27

New in 27

New in 27

workoutBuilder(_:didUpdateWorkoutZone:)

New in 27

New in 27

Not available

New in 27

Not available

CMPedometer

Yes

Yes (device-dependent)

Yes (links; no data)

Yes

Not available

CMMotionManager

Yes

Yes

Not available

Yes

Yes

CMHeadphoneMotionManager

Yes

Yes

Yes (14)

Yes

Not available

HomeKit (HMHomeManager)

Yes

Yes

Yes (Mac Catalyst only)

Yes

Yes

Core Bluetooth (CBCentralManager)

Yes

Yes

Yes

Yes

Yes

AccessorySetupKit

Yes (18)

Yes (18)

Not available

Not available (companion uses Core Bluetooth, 26+)

Not available

Core NFC (NFCNDEFReaderSession)

Yes (iPhone 7 and later)

Not available (no reader)

Not available

Not available

Not available

Background NFC tag reading

Yes (iPhone XS and later)

Not available

Not available

Not available

Not available

References