Show device location with NMEA data sources

View on GitHub

Parse NMEA sentences and use the results to show device location on the map.

Image of Show device location with NMEA data sources sample

Use case

NMEA sentences can be retrieved from an MFi GNSS/GPS accessory and parsed into a series of coordinates with additional information.

The NMEA location data source allows for detailed interrogation of the information coming from a GNSS accessory. For example, allowing you to report the number of satellites in view, accuracy of the location, etc.

How to use the sample

Tap "Source" to choose between a simulated location data source or any data source created from a connected GNSS device, and initiate the location display. Tap "Recenter" to recenter the location display. Tap "Reset" to reset the location display and location data source.

How it works

  1. Load NMEA sentences.
    • If a supported GNSS accessory is connected, the sample can get NMEA updates from it.
    • Otherwise, the sample will read mock data from a local file.
  2. Create an NMEALocationDataSource. There are 2 ways to provide updates to the data source.
    • When updates are received from a GNSS accessory or the mock data provider, push the data into NMEALocationDataSource.
    • You can initialize NMEALocationDataSource with a GNSS accessory. The data source created this way will automatically get updates from the accessory instead of requiring to push data explicitly.
  3. Set the NMEALocationDataSource to the location display's data source.
  4. Start the location display to begin receiving location and satellite updates.

Relevant API

  • Location
  • LocationDisplay
  • NMEALocationDataSource
  • NMEASatelliteInfo

About the data

A list of NMEA sentences is used to initialize a FileNMEASentenceReader object. This simulated data source provides NMEA data periodically and allows the sample to be used without a GNSS accessory.

The route taken in this sample features a 2-minute driving trip around Redlands, CA.

Additional information

To support GNSS accessory connection in an app, here are a few steps:

  • Enable Bluetooth connection in the device settings or connect via cable connection.
  • Refer to the device manufacturer's documentation to get its protocol string and add the protocol to the app’s Info.plist under the UISupportedExternalAccessoryProtocols key.
  • When working with any MFi accessory, the end user must register their iOS app with the accessory manufacturer first to whitelist their app before submitting it to the App Store for approval. This is a requirement by Apple and stated in the iOS Developer Program License Agreement.

Please read Apple's documentation below for further details.

Below is a list of protocol strings for commonly used GNSS external accessories. Please refer to the ArcGIS Field Maps documentation for model and firmware requirements.

Supported by this sample

  • com.bad-elf.gps
  • com.emlid.nmea
  • com.eos-gnss.positioningsource
  • com.geneq.sxbluegpssource

Others

  • com.amanenterprises.nmeasource
  • com.dualav.xgps150
  • com.garmin.pvt
  • com.junipersys.geode
  • com.leica-geosystems.zeno.gnss
  • com.searanllc.serial
  • com.trimble.correction, com.trimble.command (1)

(1) Some Trimble models require a proprietary SDK for NMEA output.

Tags

accessory, Bluetooth, GNSS, GPS, history, navigation, NMEA, real-time, trace

Sample Code

ShowDeviceLocationWithNMEADataSourcesView.swiftShowDeviceLocationWithNMEADataSourcesView.swiftShowDeviceLocationWithNMEADataSourcesView.Model.swiftFileNMEASentenceReader.swift
Use dark colors for code blocksCopy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
// Copyright 2023 Esri
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//   https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import ArcGIS
import SwiftUI

struct ShowDeviceLocationWithNMEADataSourcesView: View {
    /// The view model for the sample.
    @StateObject private var model = Model()

    /// The error shown in the error alert.
    @State private var error: Error?

    /// A string for GPS accuracy.
    @State private var accuracyStatus = "Accuracy info will be shown here."

    /// A string for satellite information.
    @State private var satelliteStatus = "Satellites info will be shown here."

    /// A Boolean value specifying if the "recenter" button should be disabled.
    @State private var recenterButtonIsDisabled = true

    /// A Boolean value specifying if the "reset" button should be disabled.
    @State private var resetButtonIsDisabled = true

    /// A Boolean value specifying if the "source" button should be disabled.
    @State private var sourceMenuIsDisabled = false

    var body: some View {
        MapView(map: model.map)
            .locationDisplay(model.locationDisplay)
            .overlay(alignment: .top) {
                VStack(alignment: .leading) {
                    Text(accuracyStatus)
                    Text(satelliteStatus)
                }
                .frame(maxWidth: .infinity)
                .padding(.vertical, 6)
                .background(.thinMaterial, ignoresSafeAreaEdges: .horizontal)
            }
            .task(id: model.sentenceReader.isStarted) {
                guard model.sentenceReader.isStarted else { return }
                // Push the mock data to the NMEA location data source.
                // This simulates the case where the NMEA messages coming from a hardware need to be
                // manually pushed to the data source.
                for await data in model.sentenceReader.messages {
                    // Push the data to the data source.
                    model.nmeaLocationDataSource?.pushData(data)
                }
            }
            .task(id: model.nmeaLocationDataSource?.status) {
                if let nmeaLocationDataSource = model.nmeaLocationDataSource, nmeaLocationDataSource.status == .started {
                    // Observe location display `autoPanMode` changes.
                    for await mode in model.locationDisplay.$autoPanMode {
                        recenterButtonIsDisabled = mode == .recenter
                    }
                } else {
                    recenterButtonIsDisabled = true
                }
            }
            .task(id: model.nmeaLocationDataSource?.status) {
                guard let nmeaLocationDataSource = model.nmeaLocationDataSource, nmeaLocationDataSource.status == .started else { return }
                // Observe location data source location changes.
                for await location in nmeaLocationDataSource.locations {
                    guard let nmeaLocation = location as? NMEALocation else { return }
                    let horizontalAccuracy = Measurement(
                        value: nmeaLocation.horizontalAccuracy,
                        unit: UnitLength.meters
                    )

                    let verticalAccuracy = Measurement(
                        value: nmeaLocation.verticalAccuracy,
                        unit: UnitLength.meters
                    )

                    let accuracyText = String(
                        format: "Accuracy - Horizontal: %@; Vertical: %@",
                        horizontalAccuracy.formatted(model.formatStyle),
                        verticalAccuracy.formatted(model.formatStyle)
                    )

                    accuracyStatus = accuracyText
                }
            }
            .task(id: model.nmeaLocationDataSource?.status) {
                guard let nmeaLocationDataSource = model.nmeaLocationDataSource, nmeaLocationDataSource.status == .started else { return }
                // Observe NMEA location data source's satellite changes.
                for await satellites in nmeaLocationDataSource.satellites {
                    // Update the satellites info status text.
                    let satelliteSystems = satellites.compactMap(\.system)

                    let satelliteLabels = Set(satelliteSystems)
                        .map(\.label)
                        .sorted()
                        .formatted(model.listFormatStyle)

                    let satelliteIDs = satellites
                        .map { String($0.id) }
                        .formatted(model.listFormatStyle)

                    satelliteStatus = String(
                        format: """
                                %d satellites in view
                                System(s): %@
                                IDs: %@
                                """,
                        satellites.count,
                        satelliteLabels,
                        satelliteIDs
                    )
                }
            }
            .toolbar {
                ToolbarItemGroup(placement: .bottomBar) {
                    Menu("Source") {
                        Button("Mock Data") {
                            Task {
                                do {
                                    try await model.start(usingMockedData: true)
                                    // Set buttons states.
                                    sourceMenuIsDisabled = true
                                    resetButtonIsDisabled = false
                                } catch {
                                    self.error = error
                                }
                            }
                        }
                        Button("Device") {
                            Task {
                                do {
                                    try selectDevice()
                                    try await model.start()
                                } catch {
                                    self.error = error
                                }
                            }
                        }
                    }
                    .disabled(sourceMenuIsDisabled)
                    Spacer()
                    Button("Recenter") {
                        model.locationDisplay.autoPanMode = .recenter
                    }
                    .disabled(recenterButtonIsDisabled)
                    Spacer()
                    Button("Reset") {
                        reset()
                    }
                    .disabled(resetButtonIsDisabled)
                }
            }
            .errorAlert(presentingError: $error)
            .onDisappear {
                reset()
            }
    }

    private func reset() {
        // Reset the status text.
        accuracyStatus = "Accuracy info will be shown here."
        satelliteStatus = "Satellites info will be shown here."
        // Reset buttons states.
        resetButtonIsDisabled = true
        sourceMenuIsDisabled = false
        Task {
            // Reset the model to stop the data source and observations.
            await model.reset()
        }
    }

    private func selectDevice() throws {
        if let (accessory, protocolString) = model.firstSupportedAccessoryWithProtocol() {
            // Use the supported accessory directly if it's already connected.
            model.accessoryDidConnect(connectedAccessory: accessory, protocolString: protocolString)
        } else {
            throw AccessoryError.noBluetoothDevices

            // NOTE: The code below shows how to use the built-in Bluetooth picker
            // to pair a device. However there are a couple of issues that
            // prevent the built-in picker from functioning as desired.
            // The work-around is to have the supported device connected prior
            // to running the sample. The above message will be displayed
            // if no devices with a supported protocol are connected.
            //
            // The Bluetooth accessory picker is currently not supported
            // for Apple Silicon devices - https://developer.apple.com/documentation/externalaccessory/eaaccessorymanager/1613913-showbluetoothaccessorypicker/
            // "On Apple silicon, this method displays an alert to let the user
            // know that the Bluetooth accessory picker is unavailable."
            //
            // Also, it appears that there is currently a bug with
            // `showBluetoothAccessoryPicker` - https://developer.apple.com/forums/thread/690320
            // The work-around is to ensure your device is already connected and it's
            // protocol is in the app's list of protocol strings in the plist.info table.
//            EAAccessoryManager.shared().showBluetoothAccessoryPicker(withNameFilter: nil) { error in
//                if let error = error as? EABluetoothAccessoryPickerError,
//                   error.code != .alreadyConnected {
//                    switch error.code {
//                    case .resultNotFound:
//                        self.error = AccessoryError.notFound
//                    case .resultCancelled:
//                        // Don't show error message when the picker is cancelled.
//                        return
//                    default:
//                        self.error = AccessoryError.unknown
//                    }
//                } else if let (accessory, protocolString) = model.firstSupportedAccessoryWithProtocol() {
//                    // Proceed with supported and connected accessory, and
//                    // ignore other accessories that aren't supported.
//                    model.accessoryDidConnect(connectedAccessory: accessory, protocolString: protocolString)
//                }
//            }
        }
    }
}

/// An error relating to NMEA accessories.
private enum AccessoryError: LocalizedError {
    /// No supported Bluetooth devices connected.
    case noBluetoothDevices
    /// Accessory could not be found.
    case notFound
    /// Unknown selection failure.
    case unknown

    /// The message describing what error occurred.
    var errorDescription: String? {
        let message: String
        switch self {
        case .noBluetoothDevices:
            message = "There are no supported Bluetooth devices connected. Open up \"Bluetooth Settings\", connect to your supported device, and try again."
        case .notFound:
            message = "The specified accessory could not be found, perhaps because it was turned off prior to connection."
        case .unknown:
            message = "Selecting an accessory failed for an unknown reason."
        }

        return NSLocalizedString(
            message,
            comment: "Error thrown when connecting an NMEA accessory fails."
        )
    }
}

private extension NMEAGNSSSystem {
    var label: String {
        switch self {
        case .gps:
            return "The Global Positioning System"
        case .glonass:
            return "The Russian Global Navigation Satellite System"
        case .galileo:
            return "The European Union Global Navigation Satellite System"
        case .bds:
            return "The BeiDou Navigation Satellite System"
        case .qzss:
            return "The Quasi-Zenith Satellite System"
        case .navIC:
            return "The Navigation Indian Constellation"
        default:
            return "Unknown GNSS type"
        }
    }
}

Your browser is no longer supported. Please upgrade your browser for the best experience. See our browser deprecation post for more details.