minerjoe

Using External Gps From Xamarin Forms (iOS)

Blog Post created by minerjoe Champion on Aug 23, 2019

We needed a solution to connect to an external high accuracy GPS from an iOS application being developed in Xamarin Forms.  As it was being done through Collector I knew there there had to be a solution out there.  But I was having a hard time figuring out how the devices (non-BLE bluetooth device) connected to iOS.  I finally started to find some information on Stack Overflow that pointed me towards using  EAAccessoryManager.SharedAccessoryManager to get the connected devices.  Some other searching got me to this post How to use external gps location data which went into detail on an iOS specific solution.

 

In order to do in Forms and then use from a .Net Standard library a little more plumbing is required than the straight iOS solution.  First an interface is needed that will be developed in the main shared assembly.  I kept this is simple, and also thought in terms of a pure iOS implementation and not a pluigin that could be used cross platform

 

public interface IAccessorySessionController
{
     event EventHandler<NmeaSentenceEventArgs> OnNmeaSentenceReceived;

     IEnumerable<string> AvailableAccessories { get; }
     Task OpenSession(string protocal);
     bool CloseSession();
}

 

This interface was then implemented in the iOS project.

The main piece is the OpenSession method.  This is where the one connects to the device and starts getting data.  

 

public Task OpenSession(string protocal)
{
     try
     {
          EAAccessory connectedAccessory = null;

          /* Get list of ConnectedAccessories
          //These must be defined as UISupportedExternalAccessoryProtocols in Info.plist */

          foreach (var accessory in EAAccessoryManager.SharedAccessoryManager.ConnectedAccessories)
          {
               if (accessory.ProtocolStrings.Contains(protocal))
               {
                    connectedAccessory = accessory;
                    break;
               }
          }

          if ( connectedAccessory == null ) return Task.FromResult(false);

          connectedAccessory.Disconnected += ConnectedAccessoryOnDisconnected;

          if ( _session != null ) return Task.FromResult(true);

          try
          {
               //protacal here also must be UISupportedExternalAccessoryProtocols
               _session = new EASession(connectedAccessory, protocal);
          }
          catch (Exception e)
          {
               Console.WriteLine(e);
               return Task.FromResult(false);
          }

          //Set delegate for callback
          _session.InputStream.Delegate = this;
          _session.InputStream.Schedule(NSRunLoop.Current, NSRunLoopMode.Default);
          _session.InputStream.Open();  //start receiving data

          //not writing in this case but still best to open
          _session.OutputStream.Delegate = this;
          _session.OutputStream.Schedule(NSRunLoop.Current, NSRunLoopMode.Default);
          _session.OutputStream.Open();

          return Task.FromResult(true);
     }
     catch (Exception)
     {
          return Task.FromResult(false);
     }
}

 

Not being an iOS developer I didn't really understand the part about needing the protocols in info.plist and spent a good amount of time banging my head on the table as to why I was not seeing the devices I knew were connected

 

<key>UISupportedExternalAccessoryProtocols</key>
<array>
     <string>com.comany.device</string>
</array>

 

Then it's just a matter of handling the callback and parsing the data...

 

public override void HandleEvent(NSStream theStream, NSStreamEvent streamEvent)
{
     Console.WriteLine("Arrived HandleEvent");
     switch (streamEvent)
     {
          case NSStreamEvent.None:
               Console.WriteLine("StreamEventNone");
               break;
          case NSStreamEvent.HasBytesAvailable:
               Console.WriteLine("StreamEventHasBytesAvailable");
               ReadData();
               break;
          case NSStreamEvent.HasSpaceAvailable:
               Console.WriteLine("StreamEventHasSpaceAvailable");
               // Do write operations to the device here
               break;
          case NSStreamEvent.OpenCompleted:
               Console.WriteLine("StreamEventOpenCompleted");
               break;
          case NSStreamEvent.ErrorOccurred:
               Console.WriteLine("StreamEventErroOccurred");
               break;
          case NSStreamEvent.EndEncountered:
               Console.WriteLine("StreamEventEndEncountered");
               break;
          default:
               Console.WriteLine("Stream present but no event");
               break;

     }
}

private void ReadData()
{
     uint size = 128;
     byte[] bytesReceived = new byte[size];
     _result = string.Empty;

     while ( _session.InputStream.HasBytesAvailable() )
     {
          var bytes = _session.InputStream.Read(bytesReceived, size);

          _result += Encoding.ASCII.GetString(bytesReceived, 0, (int)bytes);
          if ( bytes < 10 ) continue;

          string[] lines = _result.Split(new[] { "\n" }, StringSplitOptions.RemoveEmptyEntries);

          for (var index = 0; index < lines.Length - 1; index++)
          {
               var line = lines[index];

               OnNmeaSentenceReceived(new NmeaSentenceEventArgs(line));
          }

          _result = lines.Last();
     }
}

 

The NmeaSentenceReceived event is used to forward on the nmea sentence back to the custom LocationDataSource.  Once all that was done it was a almost trivial matter to implement the LocationDataSource in the shared project.

 

public class ExternalGpsLocationDataSource : LocationDataSource
{
     private MapPoint _mapPoint;
     private double _velocity, _course, _accuracy;

     private readonly IAccessorySessionController _accessorySessionController;

     public ExternalGpsLocationDataSource (IAccessorySessionController accessorySessionController)
     {
          _accessorySessionController = accessorySessionController;
     }

     protected override Task OnStartAsync()
     {
          _accessorySessionController.NmeaSentenceReceived += OnNmeaSentenceReceived;
          return _accessorySessionController.OpenSession("com.company.protocal");
     }

     protected override Task OnStopAsync()
     {
          _accessorySessionController.CloseSession();
          return Task.FromResult(true);
     }

     private void OnNmeaSentenceReceived(object sender, NmeaSentenceEventArgs e)
     {
          var line = e.NmeaSentence;
          try
          {
               line = line.TrimEnd('\n', '\r');

               var message = NmeaMessage.Parse(line);

               ReadLocationAttributesFromMessage(message);
          }
          catch (Exception)
          {
               //
          }

          if (_mapPoint == null || double.IsNaN(_mapPoint.X) || double.IsNaN(_mapPoint.Y))
          {
               UpdateLastKnownLocation();
               return;
          }

          UpdateLocation(new Esri.ArcGISRuntime.Location.Location(_mapPoint, _accuracy, _velocity, _course, false));

     }

     private void ReadLocationAttributesFromMessage(NmeaMessage message)
     {
          switch (message)
          {
               case Gpgga gpgga:
                    _mapPoint = new MapPoint(gpgga.Longitude, gpgga.Latitude, SpatialReference.Create(4326));
                    break;
               case Gpgsa gpgsa:

                    break;
               case Gprmc gprmc:
                    _velocity = double.IsNaN(gprmc.Speed) ? 0 : gprmc.Speed * 0.5144; //knots to m\s
                    _course = double.IsNaN(gprmc.Course) ? 0 : gprmc.Course;
                    _mapPoint = new MapPoint(gprmc.Longitude, gprmc.Latitude, SpatialReference.Create(4326));

                    break;
               case Gpgsv gpgsv:
                    break;
               case Gpgst gpgst:
                    double latError = gpgst.SigmaLatitudeError;
                    double lonError = gpgst.SigmaLongitudeError;
                               
                    _accuracy = Math.Sqrt(0.5 * (Math.Pow(latError, 2) + Math.Pow(lonError, 2)));

                    break;
          }
     }

     private void UpdateLastKnownLocation()
     {
          //If latest point not valid send previous point as LastKnown
          if (_mapPoint == null)
          {
               _mapPoint = new MapPoint(double.NaN, double.NaN, SpatialReferences.Wgs84);
          }

          UpdateLocation(new Esri.ArcGISRuntime.Location.Location(_mapPoint, _accuracy, _velocity, _course, true));
     }
}

 

I use a NMEA parser that Morten Nielsen wrote so no need to spend any time on the parsing GitHub - dotMorten/NmeaParser: Library for handling NMEA message in Windows Desktop, Store, Phone, Universal, and Xamari… 

 

Beyond that it is just replacing the DataSource property on the MapView:LocationDisplay to the custom LocationDataSource and setting Enabled = true;

 

Cheers!

Outcomes