Parse NMEA sentences and use the results to show device location on the map.
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
- 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.
- 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.
- When updates are received from a GNSS accessory or the mock data provider, push the data into
- Set the
NMEALocationDataSource
to the location display's data source. - 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 theUISupportedExternalAccessoryProtocols
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
// 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 ExternalAccessory
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()
}
}
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()
}
}
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"
}
}
}