Display device location with NMEA data sources

View on GitHubSample viewer app

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

Image of Display device location with NMEA data sources

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 AGSNMEALocationDataSource. 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 AGSNMEALocationDataSource.
    • Starting with Runtime SDK 100.11, you can initialize AGSNMEALocationDataSource 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 AGSNMEALocationDataSource to the location display's data source.
  4. Start the location display to begin receiving location and satellite updates.

Relevant API

  • AGSLocation
  • AGSLocationDisplay
  • AGSNMEALocationDataSource
  • AGSNMEASatelliteInfo

About the data

A list of NMEA sentences is used to initialize a SimulatedNMEADataSource 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.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

DisplayDeviceLocationWithNMEADataSourcesViewController.swiftDisplayDeviceLocationWithNMEADataSourcesViewController.swiftSimulatedNMEADataSource.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
275
276
// Copyright 2021 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 UIKit
import ArcGIS

class DisplayDeviceLocationWithNMEADataSourcesViewController: UIViewController {
    // MARK: Storyboard views

    /// The map view managed by the view controller.
    @IBOutlet var mapView: AGSMapView! {
        didSet {
            mapView.map = AGSMap(basemapStyle: .arcGISNavigation)
        }
    }
    /// The label to display accuracy info.
    @IBOutlet var accuracyStatusLabel: UILabel!
    /// The label to display satellites info.
    @IBOutlet var satelliteStatusLabel: UILabel!
    /// The button to choose a data source and start the demo.
    @IBOutlet var sourceBarButtonItem: UIBarButtonItem!
    /// The button to reset pan mode to "recenter".
    @IBOutlet var recenterBarButtonItem: UIBarButtonItem!
    /// The button to reset the demo.
    @IBOutlet var resetBarButtonItem: UIBarButtonItem!

    // MARK: Constants

    /// The protocols used in this sample to get NMEA sentences.
    /// They are also specified in the `Info.plist` to allow the app to
    /// communicate with external accessory hardware.
    let supportedProtocolStrings = [
        "com.bad-elf.gps",
        "com.eos-gnss.positioningsource",
        "com.geneq.sxbluegpssource"
    ]

    // MARK: Instance properties

    /// An NMEA location data source, to parse NMEA data.
    var nmeaLocationDataSource: AGSNMEALocationDataSource!
    /// A mock data source to read NMEA sentences from a local file, and generate
    /// mock NMEA data every fixed amount of time.
    let mockNMEADataSource = SimulatedNMEADataSource(nmeaSourceFile: Bundle.main.url(forResource: "Redlands", withExtension: "nmea")!, speed: 1.5)
    /// A formatter for the accuracy distance string.
    let distanceFormatter: MeasurementFormatter = {
        let formatter = MeasurementFormatter()
        formatter.unitOptions = .naturalScale
        formatter.numberFormatter.minimumFractionDigits = 1
        formatter.numberFormatter.maximumFractionDigits = 1
        return formatter
    }()

    // MARK: Actions

    /// Get the first connected and supported Bluetooth accessory with its
    /// protocol string.
    /// - Returns: A tuple of the accessory and its protocol,
    ///            or nil if no supported accessory exists.
    func firstSupportedAccessoryWithProtocol() -> (EAAccessory, String)? {
        for accessory in EAAccessoryManager.shared().connectedAccessories {
            // The protocol string to establish the EASession.
            guard let protocolString = accessory.protocolStrings.first(where: { supportedProtocolStrings.contains($0) }) else {
                // Skip the accessories with protocol not for NMEA data transfer.
                continue
            }
            // Only return the first connected and supported accessory.
            return (accessory, protocolString)
        }
        return nil
    }

    /// The Bluetooth accessory picker connected to a supported accessory.
    func accessoryDidConnect(connectedAccessory: EAAccessory, protocolString: String) {
        if let dataSource = AGSNMEALocationDataSource(eaAccessory: connectedAccessory, protocol: protocolString) {
            nmeaLocationDataSource = dataSource
            nmeaLocationDataSource.locationChangeHandlerDelegate = self
            start()
        } else {
            presentAlert(message: "NMEA location data source failed to initialize from the accessory!")
        }
    }

    @IBAction func chooseDataSource(_ sender: UIBarButtonItem) {
        let alertController = UIAlertController(
            title: "Choose an NMEA data source.",
            message: nil,
            preferredStyle: .actionSheet
        )
        // Add real data source to the options.
        let realDataSourceAction = UIAlertAction(title: "Device", style: .default) { [unowned self] _ in
            if let (accessory, protocolString) = firstSupportedAccessoryWithProtocol() {
                // Use the supported accessory directly if it's already connected.
                accessoryDidConnect(connectedAccessory: accessory, protocolString: protocolString)
            } else {
                // Show a picker to pair the device with a Bluetooth accessory.
                EAAccessoryManager.shared().showBluetoothAccessoryPicker(withNameFilter: nil) { error in
                    if let error = error as? EABluetoothAccessoryPickerError,
                       error.code != .alreadyConnected {
                        switch error.code {
                        case .resultNotFound:
                            self.presentAlert(message: "The specified accessory could not be found, perhaps because it was turned off prior to connection.")
                        case .resultCancelled:
                            // Don't show error message when the picker is cancelled.
                            return
                        default:
                            self.presentAlert(message: "Selecting an accessory failed for an unknown reason.")
                        }
                    } else if let (accessory, protocolString) = self.firstSupportedAccessoryWithProtocol() {
                        // Proceed with supported and connected accessory, and
                        // ignore other accessories that aren't supported.
                        self.accessoryDidConnect(connectedAccessory: accessory, protocolString: protocolString)
                    }
                }
            }
        }
        alertController.addAction(realDataSourceAction)
        // Add mock data source to the options.
        let mockDataSourceAction = UIAlertAction(title: "Mock Data", style: .default) { [unowned self] _ in
            nmeaLocationDataSource = AGSNMEALocationDataSource(receiverSpatialReference: .wgs84())
            nmeaLocationDataSource.locationChangeHandlerDelegate = self
            mockNMEADataSource.delegate = self
            start()
        }
        alertController.addAction(mockDataSourceAction)
        let cancelAction = UIAlertAction(title: "Cancel", style: .cancel)
        alertController.addAction(cancelAction)
        alertController.popoverPresentationController?.barButtonItem = sender
        present(alertController, animated: true)
    }

    func start() {
        // Set NMEA location data source for location display.
        mapView.locationDisplay.dataSource = nmeaLocationDataSource
        // Set buttons states.
        sourceBarButtonItem.isEnabled = false
        resetBarButtonItem.isEnabled = true
        // Start the data source and location display.
        mockNMEADataSource.start()
        mapView.locationDisplay.start()
        // Recenter the map and set pan mode.
        recenter()
    }

    @IBAction func recenter() {
        mapView.locationDisplay.autoPanMode = .recenter
        recenterBarButtonItem.isEnabled = false
        mapView.locationDisplay.autoPanModeChangedHandler = { [weak self] _ in
            DispatchQueue.main.async {
                self?.recenterBarButtonItem.isEnabled = true
            }
            self?.mapView.locationDisplay.autoPanModeChangedHandler = nil
        }
    }

    @IBAction func reset() {
        // Reset buttons states.
        resetBarButtonItem.isEnabled = false
        sourceBarButtonItem.isEnabled = true
        // Reset the status text.
        accuracyStatusLabel.text = "Accuracy info will be shown here."
        satelliteStatusLabel.text = "Satellites info will be shown here."
        // Reset and stop the location display.
        mapView.locationDisplay.autoPanModeChangedHandler = nil
        mapView.locationDisplay.autoPanMode = .off
        // Stop the location display, which in turn stop the data source.
        mapView.locationDisplay.stop()
        // Pause the mock data generation.
        mockNMEADataSource.stop()
        // Disconnect from the mock data updates.
        mockNMEADataSource.delegate = nil
        // Reset NMEA location data source.
        nmeaLocationDataSource = nil
    }

    // MARK: UIViewController

    override func viewDidLoad() {
        super.viewDidLoad()
        // Add the source code button item to the right of navigation bar.
        (navigationItem.rightBarButtonItem as? SourceCodeBarButtonItem)?.filenames = [
            "DisplayDeviceLocationWithNMEADataSourcesViewController",
            "SimulatedNMEADataSource"
        ]
        sourceBarButtonItem.isEnabled = true
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        reset()
    }
}

// MARK: SimulatedNMEADataSourceDelegate

extension DisplayDeviceLocationWithNMEADataSourcesViewController: SimulatedNMEADataSourceDelegate {
    func dataSource(_ dataSource: SimulatedNMEADataSource, didUpdate nmeaData: Data) {
        // Push mock data into the data source.
        // Note: You can also get real-time NMEA sentences from a GNSS surveyor.
        nmeaLocationDataSource.push(nmeaData)
    }
}

// MARK: AGSNMEALocationDataSourceDelegate

extension DisplayDeviceLocationWithNMEADataSourcesViewController: AGSNMEALocationDataSourceDelegate {
    func locationDataSource(_ locationDataSource: AGSLocationDataSource, locationDidChange location: AGSLocation) {
        guard let nmeaLocation = location as? AGSNMEALocation 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: %@",
            distanceFormatter.string(from: horizontalAccuracy),
            distanceFormatter.string(from: verticalAccuracy)
        )
        accuracyStatusLabel.text = accuracyText
    }

    func nmeaLocationDataSource(_ NMEALocationDataSource: AGSNMEALocationDataSource, satellitesDidChange satellites: [AGSNMEASatelliteInfo]) {
        // Update the satellites info status text.
        let satelliteSystemsText = ListFormatter.localizedString(
            byJoining: Set(satellites.map(\.system.label)).sorted()
        )
        let idText = ListFormatter.localizedString(
            byJoining: satellites.map { String($0.satelliteID) }
        )
        satelliteStatusLabel.text = String(
            format: """
            %d satellites in view
            System(s): %@
            IDs: %@
            """,
            satellites.count,
            satelliteSystemsText,
            idText
        )
    }
}

private extension AGSNMEAGNSSSystem {
    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.