diff --git a/docs/concepts/ArcGISRuntime.md b/docs/concepts/ArcGISRuntime.md index d848227..c8e8b76 100644 --- a/docs/concepts/ArcGISRuntime.md +++ b/docs/concepts/ArcGISRuntime.md @@ -8,50 +8,185 @@ You can also check out the Desktop Sample app in the [Github Repo]( https://gith **Usage:** ```csharp NmeaParser.NmeaDevice device = new NmeaParser.NmeaFileDevice("NmeaSampleData.txt"); -mapView.LocationDisplay.LocationProvider = new NmeaLocationProvider(device); +mapView.LocationDisplay.DataSource = new NmeaLocationProvider(device); +mapView.LocationDisplay.InitialZoomScale = 20000; mapView.LocationDisplay.IsEnabled = true; ``` **NmeaLocationProvider.cs** ```csharp -using System; using System.Threading.Tasks; using Esri.ArcGISRuntime.Geometry; using Esri.ArcGISRuntime.Location; +using NmeaParser; namespace NmeaParser.ArcGIS { - public class NmeaLocationProvider : ILocationProvider + public class NmeaLocationProvider : LocationDataSource { - public event EventHandler LocationChanged; private readonly NmeaParser.NmeaDevice device; public NmeaLocationProvider(NmeaParser.NmeaDevice device) { this.device = device; - device.MessageReceived += device_MessageReceived; + device.MessageReceived += NmeaMessageReceived; } - void device_MessageReceived(object sender, NmeaParser.NmeaMessageReceivedEventArgs e) + private void NmeaMessageReceived(object sender, NmeaMessageReceivedEventArgs e) { var message = e.Message; if (message is NmeaParser.Messages.Rmc rmc && rmc.Active) { - LocationChanged?.Invoke(this, new LocationInfo() - { - Course = rmc.Course, - Speed = rmc.Speed, - Location = new MapPoint(rmc.Longitude, rmc.Latitude, SpatialReferences.Wgs84) - }); + base.UpdateLocation(new Location( + new MapPoint(rmc.Longitude, rmc.Latitude, SpatialReferences.Wgs84), + horizontalAccuracy: double.NaN, + velocity: double.IsNaN(rmc.Speed) ? 0 : rmc.Speed, + course: double.IsNaN(rmc.Course) ? 0 : rmc.Course, // Current ArcGIS Runtime limitation that course can't be NaN + isLastKnown: false)); } } + protected override Task OnStartAsync() => device.OpenAsync(); - public Task StartAsync() => device.OpenAsync(); - - public Task StopAsync() => device.CloseAsync(); + protected override Task OnStopAsync() => device.CloseAsync(); } } ``` +### Combining multiple NMEA messages into a single location event +NMEA often happens in a burst of messages, which could be combined to one larger location object with more information available. +By relying on the time stamp in most of the messages, we can combine them all to get better metadata about the location. +``` +using System; +using System.Linq; +using System.Threading.Tasks; +using Esri.ArcGISRuntime.Geometry; +using Esri.ArcGISRuntime.Location; +using NmeaParser; +using NmeaParser.Messages; + +namespace NmeaParser.ArcGIS +{ + public class NmeaLocationProvider : LocationDataSource + { + private readonly NmeaParser.NmeaDevice device; + private Gga lastGga; + private Rmc lastRmc; + private Gsa lastGsa; + private Gst lastGst; + + public NmeaLocationProvider(NmeaParser.NmeaDevice device) + { + this.device = device; + device.MessageReceived += NmeaMessageReceived; + } + private void NmeaMessageReceived(object sender, NmeaMessageReceivedEventArgs e) + { + var message = e.Message; + bool newFix = false; + if (message is Rmc rmc && rmc.Active) + { + lastRmc = rmc; + newFix = true; + } + else if (message is Gga gga) + { + lastGga = gga; + newFix = true; + } + else if (message is Gst gst) + { + lastGst = gst; + newFix = true; + } + else if (message is Gsa gsa) + { + lastGsa = gsa; + } + else + { + return; + } + // We require the timestamps to match to raise them together. Gsa doesn't have a time stamp so just using latest for that + TimeSpan? timeOfFixMax = MaxTime(lastRmc?.FixTime.TimeOfDay, lastGga?.FixTime, lastGst?.FixTime); + TimeSpan? timeOfFixMin = MinTime(lastRmc?.FixTime.TimeOfDay, lastGga?.FixTime, lastGst?.FixTime); + if (newFix && timeOfFixMax == timeOfFixMin) + { + var location = NmeaLocation.Create(timeOfFixMax.Value, lastRmc, lastGga, lastGsa, lastGst); + if (location != null) + base.UpdateLocation(location); + } + } + private static TimeSpan? MaxTime(params TimeSpan?[] timeSpans) => timeSpans.Where(t => t != null).Max(); + private static TimeSpan? MinTime(params TimeSpan?[] timeSpans) => timeSpans.Where(t => t != null).Min(); + protected override Task OnStartAsync() => device.OpenAsync(); + + protected override Task OnStopAsync() => device.CloseAsync(); + } + + /// + /// Custom location class with the additional NMEA information associated with it + /// + public class NmeaLocation : Location + { + private NmeaLocation(DateTimeOffset? timestamp, MapPoint position, double horizontalAccuracy, double verticalAccuracy, double velocity, double course, bool isLastKnown) + : base(timestamp, position, horizontalAccuracy, verticalAccuracy, velocity, course, isLastKnown) + { + } + + public static NmeaLocation Create(TimeSpan timeOfFix, Rmc rmc, Gga gga, Gsa gsa, Gst gst) + { + MapPoint position = null; + double horizontalAccuracy = double.NaN; + double verticalAccuracy = double.NaN; + double velocity = 0; + double course = 0; + // Prefer GGA over RMC for location + if (gga != null && gga.FixTime == timeOfFix) + { + if (double.IsNaN(gga.Altitude)) + position = new MapPoint(gga.Longitude, gga.Latitude, SpatialReferences.Wgs84); + else + { + // Vertical id 115700 == ellipsoid reference system. Gga is geoid, but we subtract GeoidalSeparation to simplify + // vertical transformations from the simpler/better known ellipsoidal model + position = new MapPoint(gga.Longitude, gga.Latitude, gga.Altitude + gga.GeoidalSeparation, SpatialReference.Create(4326, 115700)); + } + } + if (rmc != null && rmc.FixTime.TimeOfDay == timeOfFix) + { + if (position == null) + { + position = new MapPoint(rmc.Longitude, rmc.Latitude, SpatialReferences.Wgs84); + } + velocity = double.IsNaN(rmc.Speed) ? 0 : rmc.Speed; + course = double.IsNaN(rmc.Course) ? 0 : rmc.Course; + } + if (gst != null && gst.FixTime == timeOfFix) + { + verticalAccuracy = gst.SigmaHeightError; + horizontalAccuracy = gst.SemiMajorError; + } + if (position == null) + return null; + var location = new NmeaLocation(DateTimeOffset.UtcNow.Date.Add(timeOfFix), position, horizontalAccuracy, verticalAccuracy, velocity, course, false); + location.Rmc = rmc; + location.Gga = gga; + location.Gsa = gsa; + location.Gst = gst?.FixTime == timeOfFix ? gst : null; + return location; + } + public Rmc Rmc { get; private set; } + public Gga Gga { get; private set; } + public Gsa Gsa { get; private set; } + public Gst Gst { get; private set; } + + public int NumberOfSatellites => Gga?.NumberOfSatellites ?? -1; + public double Hdop => Gsa?.Hdop ?? Gga?.Hdop ?? double.NaN; + public double Pdop => Gsa?.Pdop ?? double.NaN; + public double Vdop => Gsa?.Vdop ?? double.NaN; + } +} +``` + ![Screenshot](https://user-images.githubusercontent.com/1378165/73328707-95990e80-420f-11ea-85a7-43149e29bd21.png) diff --git a/src/NmeaParser.sln b/src/NmeaParser.sln index deb258b..d5e43f6 100644 --- a/src/NmeaParser.sln +++ b/src/NmeaParser.sln @@ -39,6 +39,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SampleApp.NetCore", "Sample EndProject Global GlobalSection(SharedMSBuildProjectFiles) = preSolution + UnitTests\NmeaParser.Tests\NmeaParser.Tests.projitems*{73efb2ef-de40-46c4-9685-745a9815c0d2}*SharedItemsImports = 5 UnitTests\NmeaParser.Tests\NmeaParser.Tests.projitems*{92cad93b-6c3b-45a0-a723-be046de50fec}*SharedItemsImports = 4 UnitTests\NmeaParser.Tests\NmeaParser.Tests.projitems*{979ae182-eb59-4181-9d45-3fd6e4817f11}*SharedItemsImports = 13 EndGlobalSection diff --git a/src/NmeaParser/BufferedStreamDevice.cs b/src/NmeaParser/BufferedStreamDevice.cs index fc5c0da..8980a7f 100644 --- a/src/NmeaParser/BufferedStreamDevice.cs +++ b/src/NmeaParser/BufferedStreamDevice.cs @@ -14,9 +14,12 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace NmeaParser @@ -28,7 +31,7 @@ namespace NmeaParser public abstract class BufferedStreamDevice : NmeaDevice { private BufferedStream? m_stream; - private readonly int m_readSpeed; + private readonly BurstEmulationSettings emulationSettings = new BurstEmulationSettings(); /// /// Initializes a new instance of the class. @@ -39,10 +42,10 @@ namespace NmeaParser /// /// Initializes a new instance of the class. /// - /// The time to wait between each group of lines being read in milliseconds - protected BufferedStreamDevice(int readSpeed) + /// The time to wait between each group of lines being read in milliseconds + protected BufferedStreamDevice(int burstRate) { - m_readSpeed = readSpeed; + BurstRate = TimeSpan.FromMilliseconds(burstRate); } /// @@ -55,63 +58,172 @@ namespace NmeaParser /// protected sealed async override Task OpenStreamAsync() { - var stream = await GetStreamAsync(); + var stream = await GetStreamAsync().ConfigureAwait(false); StreamReader sr = new StreamReader(stream); - m_stream = new BufferedStream(sr, m_readSpeed); + m_stream = new BufferedStream(sr, emulationSettings); + m_stream.EndOfStreamReached += OnEndOfStreamReached; return m_stream; } + private void OnEndOfStreamReached(object sender, EventArgs e) + { + EndOfStreamReached?.Invoke(this, e); + if (m_stream is BufferedStream stream && !stream.CanRewind && IsOpen) + { + // If we can't rewind the stream, stop + _ = CloseAsync(); + } + } + + /// + /// Gets or sets the emulated baud rate. Defaults to 115200 + /// + /// + /// Note that if the baud rate gets very low, while keeping a high , the stream will not be able to keep + /// up the burstrate. For high-frequency bursts, make sure you have a corresponding high emualated baud rate. + /// + public uint EmulatedBaudRate + { + get => emulationSettings.EmulatedBaudRate; + set => emulationSettings.EmulatedBaudRate = value; + } + + /// + /// Gets or sets the emulated burst rate - that is the frequency of each burst of messages. Defaults to 1 second (1hz). + /// + /// + /// Note that if the burst rate gets very high, while keeping a low , the stream will not be able to keep + /// up the burstrate. For high-frequency bursts, make sure you have a corresponding high emualated baud rate. + /// + public TimeSpan BurstRate + { + get => emulationSettings.BurstRate; + set + { + if (value.TotalMilliseconds < 1) + throw new ArgumentOutOfRangeException(nameof(BurstRate), "Burst rate must be at least 1 ms"); + emulationSettings.BurstRate = value; + } + } + + /// + /// Gets or sets the separator between each burst of data. Defaults to . + /// + public BurstEmulationSeparator BurstSeparator + { + get => emulationSettings.Separator; + set => emulationSettings.Separator = value; + } + /// protected override Task CloseStreamAsync(System.IO.Stream stream) { - m_stream?.Dispose(); + if (m_stream != null) + { + m_stream.EndOfStreamReached -= OnEndOfStreamReached; + m_stream?.Dispose(); + } return Task.FromResult(true); } + private class BurstEmulationSettings + { + public uint EmulatedBaudRate { get; set; } = 115200; + public TimeSpan BurstRate { get; set; } = TimeSpan.FromSeconds(1); + public BurstEmulationSeparator Separator { get; set; } + } + + /// + /// Defined how a burst of data is separated + /// + /// + public enum BurstEmulationSeparator + { + /// + /// The first NMEA token encountered will be used as an indicator for pauses between bursts + /// + FirstToken, + /// + /// An empty line in the NMEA stream should indicate a pause in the burst of messages + /// + EmptyLine + } + + /// + /// Raised when the stream has reached the end. If the stream can be revound, it'll start over, unless you stop the device in this thread. + /// + public event EventHandler? EndOfStreamReached; + // stream that slowly populates a buffer from a StreamReader to simulate nmea messages coming // in lastLineRead by lastLineRead at a steady stream private class BufferedStream : Stream { private readonly StreamReader m_sr; private byte[] m_buffer = new byte[0]; - private readonly System.Threading.Timer m_timer; private readonly object lockObj = new object(); private string? groupToken = null; - private string? lastLineRead = null; + private BurstEmulationSettings m_settings; + private CancellationTokenSource m_tcs; + private Task m_readTask; + /// /// Initializes a new instance of the class. /// /// The stream. - /// The read speed in milliseconds. - public BufferedStream(StreamReader stream, int readSpeed) + /// Emulation settings. + public BufferedStream(StreamReader stream, BurstEmulationSettings settings) { + m_settings = settings; m_sr = stream; - m_timer = new System.Threading.Timer(OnRead, null, 0, readSpeed); //read a group of lines every 'readSpeed' milliseconds + m_tcs = new CancellationTokenSource(); + m_readTask = StartReadLoop(m_tcs.Token); } - private void OnRead(object state) + + internal bool CanRewind => m_sr.BaseStream.CanSeek; + + private async Task StartReadLoop(CancellationToken cancellationToken) { - if (lastLineRead != null) - AppendToBuffer(lastLineRead); - //Get the group token if we don't have one - while (groupToken == null && (lastLineRead == null || !lastLineRead.StartsWith("$", StringComparison.Ordinal))) + await Task.Yield(); + var start = Stopwatch.GetTimestamp(); + while (!cancellationToken.IsCancellationRequested) { - lastLineRead = ReadLine(); //seek forward to first nmea token - AppendToBuffer(lastLineRead); + var line = ReadLine(); + if (line != null) + { + // Group token is the first message type received - every time we see it, we'll take a short burst break + if (groupToken == null && line.StartsWith("$", StringComparison.Ordinal)) + { + var values = line.Trim().Split(new char[] { ',' }); + if (values.Length > 0) + groupToken = values[0]; + } + if (m_settings.Separator == BurstEmulationSeparator.EmptyLine && string.IsNullOrWhiteSpace(line) || + m_settings.Separator == BurstEmulationSeparator.FirstToken && groupToken != null && line.StartsWith(groupToken, StringComparison.Ordinal)) + { + // Emulate the burst pause + var now = Stopwatch.GetTimestamp(); + var delay = (now - start) / (double)Stopwatch.Frequency; + if (delay < m_settings.BurstRate.TotalSeconds) + await Task.Delay(TimeSpan.FromSeconds(m_settings.BurstRate.TotalSeconds - delay)).ConfigureAwait(false); + else + { + Debug.WriteLine("Warning: baud rate too slow for amount of data, or burst rate too fast"); + } + if (cancellationToken.IsCancellationRequested) + return; + start = Stopwatch.GetTimestamp(); + } + if (!string.IsNullOrWhiteSpace(line)) + { + await AppendToBuffer(line).ConfigureAwait(false); + } + } } - if(groupToken == null && lastLineRead != null) - { - var values = lastLineRead.Trim().Split(new char[] { ',' }); - if (values.Length > 0) - groupToken = values[0]; - } - lastLineRead = ReadLine(); - while (!lastLineRead.StartsWith(groupToken, StringComparison.Ordinal)) //keep reading until messages start repeating again - { - AppendToBuffer(lastLineRead); - lastLineRead = ReadLine(); - } } - private void AppendToBuffer(string line) + + private double pendingDelay = 0; + + private async Task AppendToBuffer(string line) { var bytes = Encoding.UTF8.GetBytes(line); lock (lockObj) @@ -121,37 +233,54 @@ namespace NmeaParser bytes.CopyTo(newBuffer, m_buffer.Length); m_buffer = newBuffer; } + var delay = bytes.Length * 10d / m_settings.EmulatedBaudRate; // 8 bits + 1 parity + 1 stop bit = 10bits per byte; + + pendingDelay += delay; + if (pendingDelay < 0.016) //No reason to wait under the 16ms - Task.Delay not that accurate anyway + { + return; + } + // Task.Delay isn't very accurate so use the stopwatch to get the real delay and use difference to fix it later + var start = Stopwatch.GetTimestamp(); + await Task.Delay(TimeSpan.FromSeconds(pendingDelay)).ConfigureAwait(false); + var end = Stopwatch.GetTimestamp(); + pendingDelay -= (end - start) / (double)Stopwatch.Frequency; } - private string ReadLine() + + private string? ReadLine() { + if (m_tcs.IsCancellationRequested) + return null; if (m_sr.EndOfStream) + { + EndOfStreamReached?.Invoke(this, EventArgs.Empty); + if (m_tcs.IsCancellationRequested) + return null; m_sr.BaseStream.Seek(0, SeekOrigin.Begin); //start over + } return m_sr.ReadLine() + '\n'; } - - /// - public override bool CanRead { get { return true; } } /// - public override bool CanSeek { get { return false; } } + public override bool CanRead => true; /// - public override bool CanWrite { get { return false; } } + public override bool CanSeek => false; + + /// + public override bool CanWrite => false; /// public override void Flush() { } /// - public override long Length { get { return m_sr.BaseStream.Length; } } + public override long Length => m_sr.BaseStream.Length; /// public override long Position { - get { return m_sr.BaseStream.Position; } - set - { - throw new NotSupportedException(); - } + get => m_sr.BaseStream.Position; + set => throw new NotSupportedException(); } /// @@ -178,30 +307,23 @@ namespace NmeaParser } /// - public override long Seek(long offset, SeekOrigin origin) - { - throw new NotSupportedException(); - } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); /// - public override void SetLength(long value) - { - throw new NotSupportedException(); - } + public override void SetLength(long value) => throw new NotSupportedException(); /// - public override void Write(byte[] buffer, int offset, int count) - { - throw new NotSupportedException(); - } + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); /// protected override void Dispose(bool disposing) { - base.Dispose(disposing); + m_tcs.Cancel(); m_sr.Dispose(); - m_timer.Dispose(); + base.Dispose(disposing); } + + internal event EventHandler? EndOfStreamReached; } } } diff --git a/src/NmeaParser/Gnss/GnssMonitor.cs b/src/NmeaParser/Gnss/GnssMonitor.cs new file mode 100644 index 0000000..c78db20 --- /dev/null +++ b/src/NmeaParser/Gnss/GnssMonitor.cs @@ -0,0 +1,305 @@ +// ******************************************************************************* +// * 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 +// * +// * http://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. +// ****************************************************************************** + +using System; +using System.Collections.Generic; +using System.Linq; +using NmeaParser.Messages; + +namespace NmeaParser.Gnss +{ + /// + /// Helper class for monitoring GNSS messages and combine them into a single useful location info + /// + public class GnssMonitor + { + private bool m_supportGNMessages; // If device detect GN* messages, ignore all other Talker ID + private bool m_supportGGaMessages; //If device support GGA, ignore RMC for location + + /// + /// Initializes a new instance of the class. + /// + /// The NMEA device to monitor for GNSS messages + public GnssMonitor(NmeaDevice device) + { + if (device == null) + throw new ArgumentNullException(nameof(device)); + Device = device; + Device.MessageReceived += NmeaMessageReceived; + } + + /// + /// Gets the NMEA device that is being monitored + /// + public NmeaDevice Device { get; } + + private void NmeaMessageReceived(object sender, NmeaParser.NmeaMessageReceivedEventArgs e) + { + OnMessageReceived(e.Message); + } + + /// + /// Called when a message is received. + /// + /// The NMEA message that was received + protected virtual void OnMessageReceived(NmeaMessage message) + { + bool isNewFix = false; + bool lostFix = false; + double lat = 0; + double lon = 0; + AllMessages[message.MessageType] = message; + + if (message.TalkerId == NmeaParser.Talker.GlobalNavigationSatelliteSystem) + m_supportGNMessages = true; // Support for GN* messages detected + else if (m_supportGNMessages && message.TalkerId != NmeaParser.Talker.GlobalNavigationSatelliteSystem) + return; // If device supports combined GN* messages, ignore non-GN messages + + if (message is NmeaParser.Messages.Garmin.Pgrme rme) + { + HorizontalError = rme.HorizontalError; + VerticalError = rme.VerticalError; + } + else if (message is Gst gst) + { + Gst = gst; + VerticalError = gst.SigmaHeightError; + HorizontalError = Math.Round(Math.Sqrt(Gst.SigmaLatitudeError * Gst.SigmaLatitudeError + Gst.SigmaLongitudeError * Gst.SigmaLongitudeError), 3); + } + else if (message is Rmc rmc) + { + Rmc = rmc; + if (!m_supportGGaMessages) + { + if (Rmc.Active) + { + lat = Rmc.Latitude; + lon = Rmc.Longitude; + FixTime = Rmc.FixTime.TimeOfDay; + isNewFix = true; + } + else + { + lostFix = true; + } + } + } + else if (message is Dtm dtm) + { + if (Dtm?.Checksum != dtm.Checksum) + { + // Datum change + Dtm = dtm; + Latitude = double.NaN; + Longitude = double.NaN; + IsFixValid = false; + } + } + else if (message is Gga gga) + { + Gga = gga; + m_supportGGaMessages = true; + if (gga.Quality != Gga.FixQuality.Invalid) + { + lat = gga.Latitude; + lon = gga.Longitude; + GeoidHeight = gga.GeoidalSeparation; + Altitude = gga.Altitude + gga.GeoidalSeparation; //Convert to ellipsoidal height + } + if (gga.Quality == Gga.FixQuality.Invalid || gga.Quality == Gga.FixQuality.Estimated) + { + lostFix = true; + } + FixTime = Gga.FixTime; + isNewFix = true; + } + else if (message is Gsa gsa) + { + Gsa = gsa; + } + else if (message is Vtg vtg) + { + Vtg = vtg; + } + if (lostFix) + { + if (!IsFixValid) + { + IsFixValid = false; + LocationLost?.Invoke(this, EventArgs.Empty); + } + } + if (isNewFix) + { + Latitude = lat; + Longitude = lon; + IsFixValid = true; + LocationChanged?.Invoke(this, EventArgs.Empty); + } + } + + /// + /// Gets a value indicating whether the current fix is valid. + /// + /// + /// If false the provided values like and are no longer current and reflect the last known location. + /// + /// + public bool IsFixValid { get; private set; } + + /// + /// Gets the latitude for the current or last known location. + /// + /// + /// + public double Latitude { get; private set; } = double.NaN; + + /// + /// Gets the longitude for the current or last known location. + /// + /// + /// + public double Longitude { get; private set; } = double.NaN; + + /// + /// Gets the geight above the ellipsoid + /// + public double Altitude { get; private set; } = double.NaN; + /// + /// Gets the Geoid Height. Add this value to to get the Geoid heights which is roughly MSL heights. + /// + public double GeoidHeight { get; private set; } = double.NaN; + + /// + /// Gets the speed in knots + /// + public double Speed => Rmc?.Speed ?? Vtg?.SpeedKnots ?? double.NaN; + + /// + /// Gets the current cource + /// + public double Course => Rmc?.Course ?? double.NaN; + + /// + /// Gets an estimate of the horizontal error in meters + /// + public double HorizontalError { get; private set; } = double.NaN; + + /// + /// Gets an estimate of the vertical error in meters + /// + public double VerticalError { get; private set; } = double.NaN; + + /// + /// Gets the horizontal dilution of precision + /// + public double Hdop => Gsa?.Hdop ?? Gga?.Hdop ?? double.NaN; + + /// + /// Gets the 3D point dilution of precision + /// + public double Pdop => Gsa?.Pdop ?? double.NaN; + + /// + /// Gets the vertical dilution of precision + /// + public double Vdop => Gsa?.Vdop ?? double.NaN; + + /// + /// Gets the latest known GSA message. + /// + public Gsa? Gsa { get; private set; } + + /// + /// Gets the latest known GGA message. + /// + public Gga? Gga { get; private set; } + + /// + /// Gets the latest known RMC message. + /// + public Rmc? Rmc { get; private set; } + + /// + /// Gets the latest known GST message. + /// + public Gst? Gst { get; private set; } + + /// + /// Gets the latest known DTM message. + /// + public Dtm? Dtm { get; private set; } + + /// + /// Gets the latest known VTG message. + /// + public Vtg? Vtg { get; private set; } + + /// + /// Gets the current fix time + /// + public TimeSpan? FixTime { get; private set; } + + /// + /// Gets a list of satellite vehicles in the sky + /// + public IEnumerable Satellites => AllMessages.Values.OfType().SelectMany(s => s.SVs); + + /// + /// Gets the number of satellites in the sky + /// + public int SatellitesInView => AllMessages.Values.OfType().Sum(s => s.SatellitesInView); + + /// + /// Gets the quality of the current fix + /// + public Gga.FixQuality FixQuality => !IsFixValid ? Gga.FixQuality.Invalid : (Gga?.Quality ?? Gga.FixQuality.GpsFix); + + /// + /// Gets a list of all NMEA messages currently part of this location + /// + public Dictionary AllMessages { get; } = new Dictionary(); + + /// + /// Gets a value indicating the current Datum being used. + /// + public string Datum + { + get + { + if (Dtm == null) + return "WGS84"; + switch (Dtm.ReferenceDatumCode) + { + case "W84": return "WGS84"; + case "W72": return "WGS72"; + case "S85": return "SGS85"; + case "P90": return "PE90"; + default: return Dtm.ReferenceDatumCode; + } + } + } + + /// + /// Raised when a new location has been updated + /// + public event EventHandler? LocationChanged; + + /// + /// Raised if location tracking was lost + /// + /// + public event EventHandler? LocationLost; + } +} diff --git a/src/NmeaParser/Gnss/Ntrip/Carrier.cs b/src/NmeaParser/Gnss/Ntrip/Carrier.cs new file mode 100644 index 0000000..4e4990e --- /dev/null +++ b/src/NmeaParser/Gnss/Ntrip/Carrier.cs @@ -0,0 +1,37 @@ +// ******************************************************************************* +// * 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 +// * +// * http://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. +// ****************************************************************************** + +namespace NmeaParser.Gnss.Ntrip +{ + /// + /// Enumeration for the carrier used by the + /// + public enum Carrier : int + { + /// + /// None / unknown + /// + None = 0, + + /// + /// L1 wave + /// + L1 = 1, + + /// + /// L1 and L2 waves + /// + L1L2 = 2 + } +} diff --git a/src/NmeaParser/Gnss/Ntrip/Caster.cs b/src/NmeaParser/Gnss/Ntrip/Caster.cs new file mode 100644 index 0000000..dd213ad --- /dev/null +++ b/src/NmeaParser/Gnss/Ntrip/Caster.cs @@ -0,0 +1,109 @@ +// ******************************************************************************* +// * 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 +// * +// * http://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. +// ****************************************************************************** + +using System.Globalization; +using System.Net; + +namespace NmeaParser.Gnss.Ntrip +{ + /// + /// Gets metadata about the NTRIP Caster + /// + public class Caster : NtripSource + { + internal Caster (string[] d) + { + var a = d[1].Split(':'); + Address = IPAddress.Parse(a[0]); + Port = int.Parse(a[1]); + Identifier = d[3]; + Operator = d[4]; + SupportsNmea = d[5] == "1"; + CountryCode = d[6]; + Latitude = double.Parse(d[7], CultureInfo.InvariantCulture); + Longitude = double.Parse(d[8], CultureInfo.InvariantCulture); + FallbackAddress = IPAddress.Parse(d[9]); + } + + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public Caster(IPAddress address, int port, string identifier, string _operator, bool supportsNmea, string countryCode, double latitude, double longitude, IPAddress fallbackkAddress) + { + Address = address; + Port = port; + Identifier = identifier; + Operator = _operator; + SupportsNmea = supportsNmea; + CountryCode = countryCode; + Latitude = latitude; + Longitude = longitude; + FallbackAddress = fallbackkAddress; + } + + /// + /// Gets the caster IP Address + /// + public IPAddress Address { get; } + + /// + /// Gets the caster port + /// + public int Port { get; } + + /// + /// Gets the caster identifier + /// + public string Identifier { get; } + + /// + /// Gets the caster operator + /// + public string Operator { get; } + + /// + /// Gets a value indicating whether it supports NMEA + /// + public bool SupportsNmea { get; } + + /// + /// Gets the country code for the caster origin + /// + public string CountryCode { get; } + + /// + /// Gets the latitude for the caster + /// + public double Latitude { get; } + + /// + /// Gets the longitude for the caster + /// + public double Longitude { get; } + + /// + /// Gets the fallback address for the caster + /// + public IPAddress FallbackAddress { get; } + } +} diff --git a/src/NmeaParser/Gnss/Ntrip/Client.cs b/src/NmeaParser/Gnss/Ntrip/Client.cs new file mode 100644 index 0000000..1097c14 --- /dev/null +++ b/src/NmeaParser/Gnss/Ntrip/Client.cs @@ -0,0 +1,225 @@ +// ******************************************************************************* +// * 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 +// * +// * http://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. +// ****************************************************************************** + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace NmeaParser.Gnss.Ntrip +{ + /// + /// NTRIP Client for querying an NTRIP server and opening an NTRIP stream + /// + public class Client + { + private readonly string _host; + private readonly int _port; + private string? _auth; + + /// + /// Initializes a new instance of the class + /// + /// Host name + /// Port, usually 2101 + public Client(string host, int port) + { + _host = host; + _port = port; + } + + /// + /// Initializes a new instance of the class + /// + /// Host name + /// Port, usually 2101 + /// Username + /// Password + public Client(string host, int port, string username, string password) : this(host, port) + { + _auth = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(username + ":" + password)); + } + + /// + /// Gets a list of sources from the NTRIP endpoint + /// + /// + public IEnumerable GetSourceTable() + { + string data = ""; + byte[] buffer = new byte[1024]; + using (var sck = Request("")) + { + int count; + while ((count = sck.Receive(buffer)) > 0) + { + data += System.Text.Encoding.UTF8.GetString(buffer, 0, count); + } + } + var lines = data.Split('\n'); + List sources = new List(); + foreach (var item in lines) + { + var d = item.Split(';'); + if (d.Length == 0) continue; + if (d[0] == "ENDSOURCETABLE") + break; + if (d[0] == "CAS") + { + sources.Add(new Caster(d)); + } + else if (d[0] == "STR") + { + sources.Add(new NtripStream(d)); + } + } + return sources; + } + + private Socket Request(string path) + { + var sckt = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + sckt.Blocking = true; + sckt.ReceiveTimeout = 5000; + sckt.Connect(_host, _port); + string msg = $"GET /{path} HTTP/1.1\r\n"; + msg += "User-Agent: NTRIP ntripclient\r\n"; + if (_auth != null) + { + msg += "Authorization: Basic " + _auth + "\r\n"; + } + msg += "Accept: */*\r\nConnection: close\r\n"; + msg += "\r\n"; + + byte[] data = System.Text.Encoding.ASCII.GetBytes(msg); + sckt.Send(data); + return sckt; + } + + /// + /// Connects to the endpoint for the specified + /// + /// + public Stream OpenStream(NtripStream stream) + { + if (stream == null) + throw new ArgumentNullException(nameof(stream)); + return OpenStream(stream.Mountpoint); + } + + /// + /// Connects to the endpoint for the specified + /// + /// + public Stream OpenStream(string mountPoint) + { + if (mountPoint == null) + throw new ArgumentNullException(nameof(mountPoint)); + if (string.IsNullOrWhiteSpace(mountPoint)) + throw new ArgumentException(nameof(mountPoint)); + + return new NtripDataStream(() => Request(mountPoint)); + } + + private class NtripDataStream : System.IO.Stream + { + private Func m_openSocketAction; + private Socket m_socket; + + public NtripDataStream(Func openSocketAction) + { + m_openSocketAction = openSocketAction; + m_socket = openSocketAction(); + } + + public override bool CanRead => m_socket.Connected; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => -1; + + long position = 0; + public override long Position { get => position; set => throw new NotSupportedException(); } + + public override void Flush() => throw new NotSupportedException(); + + public override int Read(byte[] buffer, int offset, int count) + { + if (isDiposed) + throw new ObjectDisposedException("NTRIP Stream"); + if(!m_socket.Connected) + { + // reconnect + m_socket.Dispose(); + m_socket = m_openSocketAction(); + } + int read = m_socket.Receive(buffer, offset, count, SocketFlags.None); + position += read; + return read; + } +#if !NETSTANDARD1_4 + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + TaskCompletionSource tcs = new TaskCompletionSource(); + if (cancellationToken.CanBeCanceled) + cancellationToken.Register(() => tcs.TrySetCanceled()); + if (isDiposed) + throw new ObjectDisposedException("NTRIP Stream"); + if (!m_socket.Connected) + { + // reconnect + m_socket.Dispose(); + m_socket = m_openSocketAction(); + } + m_socket.BeginReceive(buffer, offset, count, SocketFlags.None, ReceiveCallback, tcs); + return tcs.Task; + } + + private void ReceiveCallback(IAsyncResult ar) + { + TaskCompletionSource tcs = (TaskCompletionSource)ar.AsyncState; + if (tcs.Task.IsCanceled) return; + try + { + int bytesRead = m_socket.EndReceive(ar); + position += bytesRead; + tcs.TrySetResult(bytesRead); + } + catch (System.Exception ex) + { + tcs.TrySetException(ex); + } + } +#endif + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + protected override void Dispose(bool disposing) + { + isDiposed = true; + m_socket.Dispose(); + base.Dispose(disposing); + } + private bool isDiposed; + public override int ReadTimeout { get => m_socket.ReceiveTimeout; set => m_socket.ReceiveTimeout = value; } + } + } +} diff --git a/src/NmeaParser/Gnss/Ntrip/NtripSource.cs b/src/NmeaParser/Gnss/Ntrip/NtripSource.cs new file mode 100644 index 0000000..95da81b --- /dev/null +++ b/src/NmeaParser/Gnss/Ntrip/NtripSource.cs @@ -0,0 +1,29 @@ +// ******************************************************************************* +// * 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 +// * +// * http://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. +// ****************************************************************************** + +namespace NmeaParser.Gnss.Ntrip +{ + /// + /// Baseclass for the sources returned from an NTRIP Service + /// + public abstract class NtripSource + { + /// + /// Initializes a new instance of the class. + /// + protected NtripSource() + { + } + } +} diff --git a/src/NmeaParser/Gnss/Ntrip/NtripStream.cs b/src/NmeaParser/Gnss/Ntrip/NtripStream.cs new file mode 100644 index 0000000..77e4da5 --- /dev/null +++ b/src/NmeaParser/Gnss/Ntrip/NtripStream.cs @@ -0,0 +1,94 @@ +// ******************************************************************************* +// * 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 +// * +// * http://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. +// ****************************************************************************** + +using System; +using System.Globalization; + +namespace NmeaParser.Gnss.Ntrip +{ + /// + /// Metadata on an NTRIP Data Stream + /// + public class NtripStream : NtripSource + { + internal NtripStream(string[] d) + { + Mountpoint = d[1]; + Identifier = d[2]; + Format = d[3]; + FormatDetails = d[4]; + if (int.TryParse(d[5], out int carrier)) + Carrier = (Carrier)carrier; + else + { + + } + Network = d[7]; + CountryCode = d[8]; + Latitude = double.Parse(d[9], CultureInfo.InvariantCulture); + Longitude = double.Parse(d[10], CultureInfo.InvariantCulture); + SupportsNmea = d[11] == "1"; + } + + /// + /// The mountpoint used with + /// + public string Mountpoint { get; } + + /// + /// Gets the unique identifier for the stream + /// + public string Identifier { get; } + + /// + /// Gets the stream format + /// + public string Format { get; } + + /// + /// Gets the details about the format + /// + public string FormatDetails { get; } + + /// + /// Gets the wave carrier for the stream + /// + public Carrier Carrier { get; } + + /// + /// Gets the network for the stream + /// + public string Network { get; } + + /// + /// Gets the country code for where the stream originates + /// + public string CountryCode { get; } + + /// + /// Gets the latitude location of the base station + /// + public double Latitude { get; } + + /// + /// Gets the longitude location of the base station + /// + public double Longitude { get; } + + /// + /// Gets a value indicating whether the stream supports NMEA + /// + public bool SupportsNmea { get; } + } +} diff --git a/src/NmeaParser/Nmea/Dtm.cs b/src/NmeaParser/Nmea/Dtm.cs new file mode 100644 index 0000000..35516fc --- /dev/null +++ b/src/NmeaParser/Nmea/Dtm.cs @@ -0,0 +1,149 @@ +// ******************************************************************************* +// * 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 +// * +// * http://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. +// ****************************************************************************** + +using System; + +namespace NmeaParser.Messages +{ + /// + /// Local geodetic datum and datum offsets from a reference datum. + /// + /// + /// This sentence is used to define the datum to which a position location and geographic + /// locations in subsequent sentences, is referenced. Latitude, longitude and altitude offsets + /// from the reference datum, and the selection of reference datum, are also provided. + /// + /// The datum sentence should be transmitted immediately prior to every positional sentence (e.g., GLL, + /// BWC, WPL) that is referenced to a datum other than WGS84, which is the datum recommended by IMO. + /// + /// + /// For all datums the DTM sentence should be transmitted prior to any datum change and periodically at + /// intervals of not greater than 30 seconds. + /// + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "Dtm")] + [NmeaMessageType("--DTM")] + public class Dtm : NmeaMessage + { + /// + /// Initializes a new instance of the class. + /// + /// The message type + /// The NMEA message values. + public Dtm (string type, string[] message) : base(type, message) + { + if (message == null || message.Length < 8) + throw new ArgumentException("Invalid DTM", "message"); + LocalDatumCode = message[0]; + if (message[1].Length > 0) + LocalDatumSubdivisionCode = message[1][0]; + LatitudeOffset = NmeaMessage.StringToDouble(message[2]) * (message[3]=="S" ? -1 : 1); + LongitudeOffset = NmeaMessage.StringToDouble(message[4]) * (message[5] == "W" ? -1 : 1); + AltitudeOffset = NmeaMessage.StringToDouble(message[6]); + ReferenceDatumCode = message[7]; + } + + /// + /// Local datum code + /// + /// + /// Three character alpha code for local datum. If not one of the listed earth-centered datums, or 999 + /// for user defined datum, use IHO datum code from International Hydrographic Organization Publication S-60 + /// Appendices B and C. String.Empty if unknown. + /// + /// Users should be aware that chart transformations based on IHO S60 parameters may result in significant + /// positional errors when applied to chart data. + /// + /// + /// Common known datum codes are: + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + ///
CodeDatum
W84WGS 84
W72WGS 72
S85SGS 85
P90PE 90
999User Defined
OthersIHO Datum Code
+ ///
+ ///
+ public string LocalDatumCode { get; } + + /// + /// Local datum subdivision code. + /// + /// + /// One character subdivision datum code when available or user defined reference character + /// for user defined datums, null field otherwise. Subdivision character from IHO Publication S-60 + /// Appendices B and C. + /// + public char? LocalDatumSubdivisionCode { get; } + + /// + /// Latitude Offset, decimal degrees + /// + /// + /// Latitude and longitude offsets are positive numbers, the altitude offset may be negative. Offsets + /// change with position; position in the local datum is offset from the position in the reference datum in the directions + /// indicated: + /// P_local_datum = P_ref_datum + offset + /// + public double LatitudeOffset { get; } + + /// + /// Longitude Offset in minutes + /// + /// + /// Latitude and longitude offsets are positive numbers, the altitude offset may be negative. Offsets + /// change with position; position in the local datum is offset from the position in the reference datum in the directions + /// indicated: + /// P_local_datum = P_ref_datum + offset + /// + public double LongitudeOffset { get; } + + /// + /// Altitude Offset in minutes + /// + /// + /// Latitude and longitude offsets are positive numbers, the altitude offset may be negative. Offsets + /// change with position; position in the local datum is offset from the position in the reference datum in the directions + /// indicated: + /// P_local_datum = P_ref_datum + offset + /// + public double AltitudeOffset { get; } + + /// + /// Reference datum code + /// + /// + /// + /// Common known datum codes are: + /// + /// + /// + /// + /// + /// + /// + /// + /// + ///
CodeDatum
W84WGS 84
W72WGS 72
S85SGS 85
P90PE 90
+ ///
+ ///
+ public string ReferenceDatumCode { get; } + } +} \ No newline at end of file diff --git a/src/NmeaParser/Nmea/Gbs.cs b/src/NmeaParser/Nmea/Gbs.cs new file mode 100644 index 0000000..c83edc6 --- /dev/null +++ b/src/NmeaParser/Nmea/Gbs.cs @@ -0,0 +1,135 @@ +// ******************************************************************************* +// * 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 +// * +// * http://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. +// ****************************************************************************** + +using System; +using System.Globalization; + +namespace NmeaParser.Messages +{ + /// + /// GNSS Satellite Fault Detection + /// + /// + /// + /// This sentence is used to support Receiver Autonomous Integrity Monitoring (RAIM). Given that a GNSS + /// receiver is tracking enough satellites to perform integrity checks of the positioning quality of the position + /// solution a sentence is needed to report the output of this process to other systems to advise the system + /// user.With the RAIM in the GNSS receiver, the receiver can isolate faults to individual satellites and not + /// use them in its position and velocity calculations.Also, the GNSS receiver can still track the satellite and + /// easily judge when it is back within tolerance.This sentence shall be used for reporting this RAIM + /// information. To perform this integrity function, the GNSS receiver must have at least two observables in + /// addition to the minimum required for navigation.Normally these observables take the form of additional + /// redundant satellites. + /// + /// + /// If only GPS, GLONASS, Galileo, BDS, QZSS, NavIC (IRNSS) is used for the reported position solution + /// the talker ID is GP, GL, GA, GB, GQ, GI respectively and the errors pertain to the individual system.If + /// satellites from multiple systems are used to obtain the reported position solution the talker ID is GN and + /// the errors pertain to the combined solution. + /// + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "Dtm")] + [NmeaMessageType("--GBS")] + public class Gbs : NmeaMessage, ITimestampedMessage + { + /// + /// Initializes a new instance of the class. + /// + /// The message type + /// The NMEA message values. + public Gbs (string type, string[] message) : base(type, message) + { + if (message == null || message.Length < 8) + throw new ArgumentException("Invalid GBS", "message"); + FixTime = StringToTimeSpan(message[0]); + LatitudeError = NmeaMessage.StringToDouble(message[1]); + LongitudeError = NmeaMessage.StringToDouble(message[2]); + AltitudeError = NmeaMessage.StringToDouble(message[3]); + if (int.TryParse(message[4], System.Globalization.NumberStyles.Integer, CultureInfo.InvariantCulture, out int id)) + SatelliteId = id; + MissedDetectionProbability = NmeaMessage.StringToDouble(message[5]); + BiasEstimate = NmeaMessage.StringToDouble(message[6]); + StandardDeviation = NmeaMessage.StringToDouble(message[7]); + } + /// + /// UTC time of the GGA or GNS fix associated with this sentence. + /// + public TimeSpan FixTime { get; } + + TimeSpan ITimestampedMessage.Timestamp => FixTime; + + /// + /// Expected Error in latitude + /// + /// + /// Expected error in meters due to bias, with noise = 0 + /// + public double LatitudeError { get; } + + /// + /// Expected Error in longitude + /// + /// + /// Expected error in meters due to bias, with noise = 0 + /// + public double LongitudeError { get; } + + /// + /// Expected Error in altitude + /// + /// + /// Expected error in meters due to bias, with noise = 0 + /// + public double AltitudeError { get; } + + /// + /// ID number of most likely failed satellite + /// + /// + /// + /// Satellite ID numbers. To avoid possible confusion caused by repetition of satellite ID numbers when using + /// multiple satellite systems, the following convention has been adopted: + ///
    + ///
  • a) GPS satellites are identified by their PRN numbers, which range from 1 to 32.
  • + ///
  • b) The numbers 33-64 are reserved for SBAS satellites. The SBAS system PRN numbers are 120-138. + /// The offset from NMEA SBAS SV ID to SBAS PRN number is 87. A SBAS PRN number of 120 + /// minus 87 yields the SV ID of 33. The addition of 87 to the SV ID yields the SBAS PRN number.
  • + ///
  • c) The numbers 65-96 are reserved for GLONASS satellites. GLONASS satellites are identified by + /// 64+satellite slot number.The slot numbers are 1 through 24 for the full GLONASS constellation + /// of 24 satellites, this gives a range of 65 through 88. The numbers 89 through 96 are available if + /// slot numbers above 24 are allocated to on-orbit spares. + ///
  • + ///
  • See Note 3 for other GNSS not listed in a), b), or c) above to determine meaning of satellite ID when Talker ID GN is used
  • + ///
+ ///
+ /// + ///
+ public int? SatelliteId { get; } + + /// + /// Probability of missed detection for most likely failed satellite + /// + public double MissedDetectionProbability { get; } + + /// + /// Estimate of bias in meters on most likely failed satellite + /// + public double BiasEstimate { get; } + + /// + /// Standard deviation of bias estimate + /// + public double StandardDeviation { get; } + } +} \ No newline at end of file diff --git a/src/NmeaParser/Nmea/Gga.cs b/src/NmeaParser/Nmea/Gga.cs index bd75c56..b191bd9 100644 --- a/src/NmeaParser/Nmea/Gga.cs +++ b/src/NmeaParser/Nmea/Gga.cs @@ -26,7 +26,7 @@ namespace NmeaParser.Messages /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "Gpgga")] [NmeaMessageType("--GGA")] - public class Gga : NmeaMessage + public class Gga : NmeaMessage, ITimestampedMessage, IGeographicLocation { /// /// Initializes a new instance of the class. @@ -51,7 +51,7 @@ namespace NmeaParser.Messages if (!double.IsNaN(timeInSeconds)) TimeSinceLastDgpsUpdate = TimeSpan.FromSeconds(timeInSeconds); else - TimeSinceLastDgpsUpdate = TimeSpan.MaxValue; + TimeSinceLastDgpsUpdate = null; if (message[13].Length > 0) DgpsStationId = int.Parse(message[13], CultureInfo.InvariantCulture); else @@ -116,13 +116,15 @@ namespace NmeaParser.Messages /// /// Time since last DGPS update (ie age of the differential GPS data) /// - public TimeSpan TimeSinceLastDgpsUpdate { get; } + public TimeSpan? TimeSinceLastDgpsUpdate { get; } /// /// Differential Reference Station ID /// public int DgpsStationId { get; } + TimeSpan ITimestampedMessage.Timestamp => FixTime; + /// /// Fix quality indicater /// diff --git a/src/NmeaParser/Nmea/Gll.cs b/src/NmeaParser/Nmea/Gll.cs index 625d0dd..cc2b4f1 100644 --- a/src/NmeaParser/Nmea/Gll.cs +++ b/src/NmeaParser/Nmea/Gll.cs @@ -24,7 +24,7 @@ namespace NmeaParser.Messages /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "Gll")] [NmeaMessageType("--GLL")] - public class Gll : NmeaMessage + public class Gll : NmeaMessage, ITimestampedMessage, IGeographicLocation { /// /// Initializes a new instance of the class. @@ -85,6 +85,8 @@ namespace NmeaParser.Messages /// public Mode ModeIndicator { get; } + TimeSpan ITimestampedMessage.Timestamp => FixTime; + /// /// Positioning system Mode Indicator /// diff --git a/src/NmeaParser/Nmea/Gns.cs b/src/NmeaParser/Nmea/Gns.cs index 4afc73e..4214835 100644 --- a/src/NmeaParser/Nmea/Gns.cs +++ b/src/NmeaParser/Nmea/Gns.cs @@ -38,7 +38,7 @@ namespace NmeaParser.Messages /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "Gns")] [NmeaMessageType("--GNS")] - public class Gns : NmeaMessage + public class Gns : NmeaMessage, ITimestampedMessage, IGeographicLocation { /* * Example of GNS messages: @@ -149,13 +149,13 @@ namespace NmeaParser.Messages ModeIndicators = message[5].Select(t => ParseModeIndicator(t)).ToArray(); NumberOfSatellites = int.Parse(message[6], CultureInfo.InvariantCulture); Hdop = NmeaMessage.StringToDouble(message[7]); - OrhometricHeight = NmeaMessage.StringToDouble(message[8]); + OrthometricHeight = NmeaMessage.StringToDouble(message[8]); GeoidalSeparation = NmeaMessage.StringToDouble(message[9]); var timeInSeconds = StringToDouble(message[10]); if (!double.IsNaN(timeInSeconds)) TimeSinceLastDgpsUpdate = TimeSpan.FromSeconds(timeInSeconds); else - TimeSinceLastDgpsUpdate = TimeSpan.MaxValue; + TimeSinceLastDgpsUpdate = null; if (message[11].Length > 0) DgpsStationId = message[11]; @@ -236,7 +236,7 @@ namespace NmeaParser.Messages /// /// Orthometric height in meters (MSL reference) /// - public double OrhometricHeight { get; } + public double OrthometricHeight { get; } /// /// Geoidal separation in meters - the difference between the earth ellipsoid surface and mean-sea-level (geoid) surface defined by the reference datum used in the position solution
@@ -247,7 +247,7 @@ namespace NmeaParser.Messages /// /// Age of differential data - if talker ID is GN, additional GNS messages follow with GP and/or GL Age of differential data /// - public TimeSpan TimeSinceLastDgpsUpdate { get; } + public TimeSpan? TimeSinceLastDgpsUpdate { get; } /// /// eference station ID1, range 0000-4095 - Null if talker ID is GN, additional GNS messages follow with GP and/or GL Reference station ID @@ -258,5 +258,7 @@ namespace NmeaParser.Messages /// Navigational status /// public NavigationalStatus Status { get; } + + TimeSpan ITimestampedMessage.Timestamp => FixTime; } } diff --git a/src/NmeaParser/Nmea/Grs.cs b/src/NmeaParser/Nmea/Grs.cs new file mode 100644 index 0000000..8e99a61 --- /dev/null +++ b/src/NmeaParser/Nmea/Grs.cs @@ -0,0 +1,134 @@ +// ******************************************************************************* +// * 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 +// * +// * http://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. +// ****************************************************************************** + +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace NmeaParser.Messages +{ + /// + /// GNSS Range Residuals + /// + /// + /// + /// This sentence is used to support Receiver Autonomous Integrity Monitoring (RAIM). Range residuals can be + /// computed in two ways for this process. The basic measurement integration cycle of most navigation filters + /// generates a set of residuals and uses these to update the position state of the receiver. + /// + /// + /// These residuals can be reported with GRS, but because of the fact that these were used to generate the navigation + /// solution they should be recomputed using the new solution in order to reflect the residuals for the position solution in + /// the GGA or GNS sentence. + /// + /// + /// The MODE field should indicate which computation method was used. An integrity process that uses these + /// range residuals would also require GGA or GNS, GSA, and GSV sentences to be sent. + /// + /// + /// If only GPS, or GLONASS, or Galileo, or BDS, or QZSS, or NavIC (IRNSS)is used for the reported position + /// solution, the talker ID is GP, GL, GA, GB, GQ, GI respectively and the range residuals pertain to the individual + /// system. + /// + /// + /// If GPS, GLONASS, Galileo, BDS, QZSS, NavIC (IRNSS) are combined to obtain the position solution multiple + /// GRS sentences are produced, one with the GPS satellites, another with the GLONASS satellites, etc. Each of these + /// GRS sentences shall have talker ID “GN”, to indicate that the satellites are used in a combined solution. The GNSS + /// System ID data field identifies the specific satellite system. It is important to distinguish the residuals from those that + /// would be produced by a GPS-only, GLONASS-only, etc. position solution. In general, the residuals for a combined + /// solution will be different from the residual for a GPS-only, GLONASS-only, etc. solution. + /// + /// + /// When multiple GRS sentences are necessary, use of the NMEA TAG Block structure (§ 7) and the TAG Block + /// Sentence-grouping Parameter (§ 7.9.3) reliably links the related sentences together over any transport medium. + /// + /// + /// When GRS sentences are provided with related GSA and/or GSV sentences, use of the NMEA TAG Block structure + /// (§ 7) and the TAG Block Sentence-grouping Parameter (§ 7.9.3) reliably links the related (different sentence + /// formatters) sentences together over any transport medium. + /// + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "Dtm")] + [NmeaMessageType("--GRS")] + public class Grs : NmeaMessage, ITimestampedMessage + { + /// + /// Determines the way the residuals were calculated. + /// + public enum GrsMode + { + /// + /// Residuals were used to calculate the position given in the matching GGA or GNS sentence + /// + UsedForPosition, + /// + /// Residuals were recomputed after the GGA or GNS position was computed + /// + RecomputedFromPosition + } + + /// + /// Initializes a new instance of the class. + /// + /// The message type + /// The NMEA message values. + public Grs (string type, string[] message) : base(type, message) + { + if (message == null || message.Length < 8) + throw new ArgumentException("Invalid Grs", "message"); + FixTime = StringToTimeSpan(message[0]); + Mode = message[1] == "1" ? GrsMode.RecomputedFromPosition : GrsMode.UsedForPosition; + double[] residuals = new double[message.Length - 2]; + for (int i = 2; i < message.Length; i++) + { + residuals[i-2] = NmeaMessage.StringToDouble(message[i]); + } + Residuals = residuals; + } + + /// + /// UTC time of the GGA or GNS fix associated with this sentence + /// + public TimeSpan FixTime { get; } + + /// + /// Residual calculation mode + /// + public GrsMode Mode { get; } + + TimeSpan ITimestampedMessage.Timestamp => FixTime; + + /// + /// Range residuals in meters for satellites used in the navigation solution + /// + /// + /// + /// Order must match order of the satellite ID3 numbers in GSA. When GRS is used GSA and GSV are generally required + /// + /// + /// Notes: + ///
    + ///
  • If the range residual exceeds +99.9 meters, then the decimal part is dropped, resulting in an integer (-103.7 becomes -103). + /// The maximum value for this field is +999.
  • + ///
  • The sense or sign of the range residual is determined by the order of parameters used in the calculation. The + /// expected order is as follows: range residual = calculated range - measured range.
  • + ///
  • When multiple GRS sentences are being sent then their order of transmission must match the order of + /// corresponding GSA sentences.Listeners shall keep track of pairs of GSA and GRS sentences and discard data + /// if pairs are incomplete.
  • + ///
+ ///
+ ///
+ public double[] Residuals { get; } + } +} \ No newline at end of file diff --git a/src/NmeaParser/Nmea/Gst.cs b/src/NmeaParser/Nmea/Gst.cs index bf0f7a1..f3e09e0 100644 --- a/src/NmeaParser/Nmea/Gst.cs +++ b/src/NmeaParser/Nmea/Gst.cs @@ -21,7 +21,7 @@ namespace NmeaParser.Messages ///
[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "Gpgst")] [NmeaMessageType("--GST")] - public class Gst : NmeaMessage + public class Gst : NmeaMessage, ITimestampedMessage { /// /// Initializes a new instance of the class. @@ -82,5 +82,7 @@ namespace NmeaParser.Messages /// Standard deviation of altitude error in meters. /// public double SigmaHeightError { get; } + + TimeSpan ITimestampedMessage.Timestamp => FixTime; } } diff --git a/src/NmeaParser/Nmea/Gsv.cs b/src/NmeaParser/Nmea/Gsv.cs index ade6a3f..15ad778 100644 --- a/src/NmeaParser/Nmea/Gsv.cs +++ b/src/NmeaParser/Nmea/Gsv.cs @@ -157,6 +157,19 @@ namespace NmeaParser.Messages return SatelliteSystem.Unknown; } } + + /// + public override string ToString() + { + switch (TalkerId) + { + case Talker.GlobalPositioningSystem: return $"GPS{Id}"; + case Talker.GlonassReceiver: return $"GLO{Id}"; + case Talker.GalileoPositioningSystem: return $"GAL{Id}"; + case Talker.BeiDouNavigationSatelliteSystem: return $"BEI{Id}"; + default: return Id.ToString(); + } + } } /// diff --git a/src/NmeaParser/Nmea/IGeographicLocation.cs b/src/NmeaParser/Nmea/IGeographicLocation.cs new file mode 100644 index 0000000..4c5bf73 --- /dev/null +++ b/src/NmeaParser/Nmea/IGeographicLocation.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace NmeaParser.Messages +{ + /// + /// Indicates a message that contains a latitude and longitude value + /// + public interface IGeographicLocation + { + /// + /// Gets the latitude component of the location + /// + double Latitude { get; } + + /// + /// Gets the longitude component of the location + /// + double Longitude { get; } + } +} diff --git a/src/NmeaParser/Nmea/ITimestampedMessage.cs b/src/NmeaParser/Nmea/ITimestampedMessage.cs new file mode 100644 index 0000000..1911309 --- /dev/null +++ b/src/NmeaParser/Nmea/ITimestampedMessage.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace NmeaParser.Messages +{ + /// + /// Indicates this message has a time stamp + /// + public interface ITimestampedMessage + { + /// + /// Gets the time of day for the message + /// + TimeSpan Timestamp { get; } + } +} diff --git a/src/NmeaParser/Nmea/NmeaMessage.cs b/src/NmeaParser/Nmea/NmeaMessage.cs index 2df5b5b..14db334 100644 --- a/src/NmeaParser/Nmea/NmeaMessage.cs +++ b/src/NmeaParser/Nmea/NmeaMessage.cs @@ -65,6 +65,7 @@ namespace NmeaParser.Messages { MessageType = messageType; MessageParts = messageParts; + Timestamp = System.Diagnostics.Stopwatch.GetTimestamp() * 1000d / System.Diagnostics.Stopwatch.Frequency; } static NmeaMessage() @@ -137,38 +138,58 @@ namespace NmeaParser.Messages /// /// The NMEA message string. /// The previously received message (only used if parsing multi-sentence messages) + /// If true ignores the checksum completely, if false validates the checksum if present. /// The nmea message that was parsed. /// /// Invalid nmea message: Missing starting character '$' /// or checksum failure /// - public static NmeaMessage Parse(string message, IMultiSentenceMessage? previousSentence = null) + public static NmeaMessage Parse(string message, IMultiSentenceMessage? previousSentence = null, bool ignoreChecksum = false) { if (string.IsNullOrEmpty(message)) throw new ArgumentNullException(nameof(message)); int checksum = -1; if (message[0] != '$') - throw new ArgumentException("Invalid nmea message: Missing starting character '$'"); + throw new ArgumentException("Invalid NMEA message: Missing starting character '$'"); var idx = message.IndexOf('*'); if (idx >= 0) { - checksum = Convert.ToInt32(message.Substring(idx + 1), 16); + if (message.Length > idx + 1) + { + if (int.TryParse(message.Substring(idx + 1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int c)) + checksum = c; + else + throw new ArgumentException("Invalid checksum string"); + } message = message.Substring(0, message.IndexOf('*')); } - if (checksum > -1) + if (!ignoreChecksum && checksum > -1) { int checksumTest = 0; for (int i = 1; i < message.Length; i++) { - checksumTest ^= Convert.ToByte(message[i]); + var c = message[i]; + if (c < 0x20 || c > 0x7E) + throw new System.IO.InvalidDataException("NMEA Message contains invalid characters"); + checksumTest ^= Convert.ToByte(c); } if (checksum != checksumTest) - throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Invalid nmea message: Checksum failure. Got {0:X2}, Expected {1:X2}", checksum, checksumTest)); + throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Invalid NMEA message: Checksum failure. Got {0:X2}, Expected {1:X2}", checksum, checksumTest)); + } + else + { + for (int i = 1; i < message.Length; i++) + { + if (message[i] < 0x20 || message[i] > 0x7E) + throw new System.IO.InvalidDataException("NMEA Message contains invalid characters"); + } } string[] parts = message.Split(new char[] { ',' }); string MessageType = parts[0].Substring(1); + if (MessageType == string.Empty) + throw new ArgumentException("Missing NMEA Message Type"); string[] MessageParts = parts.Skip(1).ToArray(); if(previousSentence is NmeaMessage pmsg && pmsg.MessageType.Substring(2) == MessageType.Substring(2)) { @@ -233,7 +254,9 @@ namespace NmeaParser.Messages checksumTest ^= 0x2C; //Comma separator for (int i = 0; i < message.Length; i++) { - checksumTest ^= Convert.ToByte(message[i]); + var c = message[i]; + if (c < 256) + checksumTest ^= Convert.ToByte(c); } } return Convert.ToByte(checksumTest); @@ -277,5 +300,14 @@ namespace NmeaParser.Messages } return TimeSpan.Zero; } + + /// + /// Gets a relative timestamp in milliseconds indicating the time the message was created. + /// + /// + /// This value is deduced from System.Diagnostics.Stopwatch.GetTimestamp() * 1000d / System.Diagnostics.Stopwatch.Frequency. + /// You can use it to calculate the age of the message in milliseconds by calculating the difference between the timestamp and the above expression + /// + public double Timestamp { get; } } } diff --git a/src/NmeaParser/Nmea/Rma.cs b/src/NmeaParser/Nmea/Rma.cs index f2578de..c408c11 100644 --- a/src/NmeaParser/Nmea/Rma.cs +++ b/src/NmeaParser/Nmea/Rma.cs @@ -26,7 +26,7 @@ namespace NmeaParser.Messages /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "Gprmb")] [NmeaMessageType("--RMA")] - public class Rma : NmeaMessage + public class Rma : NmeaMessage, IGeographicLocation { /// /// Positioning system status field diff --git a/src/NmeaParser/Nmea/Rmb.cs b/src/NmeaParser/Nmea/Rmb.cs index f3ee0e8..0635c02 100644 --- a/src/NmeaParser/Nmea/Rmb.cs +++ b/src/NmeaParser/Nmea/Rmb.cs @@ -21,7 +21,7 @@ namespace NmeaParser.Messages /// Recommended minimum navigation information /// /// - /// Navigation data from present position to a destination waypoint provided by a Loran-C, GNSS, DECCA, navigatin computer + /// Navigation data from present position to a destination waypoint provided by a Loran-C, GNSS, DECCA, navigation computer /// or other integrated navigation system. /// /// This sentence always accompanies and sentences when a destination is active when provided by a Loran-C or GNSS receiver, diff --git a/src/NmeaParser/Nmea/Rmc.cs b/src/NmeaParser/Nmea/Rmc.cs index df2cadf..f9a8771 100644 --- a/src/NmeaParser/Nmea/Rmc.cs +++ b/src/NmeaParser/Nmea/Rmc.cs @@ -28,7 +28,7 @@ namespace NmeaParser.Messages /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "Gprmc")] [NmeaMessageType("--RMC")] - public class Rmc : NmeaMessage + public class Rmc : NmeaMessage, ITimestampedMessage, IGeographicLocation { /// /// Initializes a new instance of the class. @@ -93,5 +93,7 @@ namespace NmeaParser.Messages /// Magnetic Variation /// public double MagneticVariation { get; } + + TimeSpan ITimestampedMessage.Timestamp => FixTime.TimeOfDay; } } diff --git a/src/NmeaParser/Nmea/Vlw.cs b/src/NmeaParser/Nmea/Vlw.cs new file mode 100644 index 0000000..a0b2c5d --- /dev/null +++ b/src/NmeaParser/Nmea/Vlw.cs @@ -0,0 +1,56 @@ +// ******************************************************************************* +// * 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 +// * +// * http://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. +// ****************************************************************************** + +using System; + +namespace NmeaParser.Messages +{ + /// + /// Dual Ground/Water Distance + /// + /// + /// The distance traveled, relative to the water and over the ground. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "Dtm")] + [NmeaMessageType("--VLW")] + public class Vlw : NmeaMessage + { + /// + /// Initializes a new instance of the class. + /// + /// The message type + /// The NMEA message values. + public Vlw (string type, string[] message) : base(type, message) + { + if (message == null || message.Length < 7) + throw new ArgumentException("Invalid VLW", "message"); + WaterDistanceCumulative = NmeaMessage.StringToDouble(message[0]); + WaterDistanceSinceReset = NmeaMessage.StringToDouble(message[2]); + GroundDistanceCumulative = NmeaMessage.StringToDouble(message[4]); + GroundDistanceSinceReset = NmeaMessage.StringToDouble(message[6]); + } + + /// Total cumulative water distance, nautical miles + public double WaterDistanceCumulative { get; } + + /// Water distance since reset, nautical miles + public double WaterDistanceSinceReset { get; } + + /// Total cumulative ground distance, nautical miles + public double GroundDistanceCumulative { get; } + + /// Ground distance since reset, nautical miles + public double GroundDistanceSinceReset { get; } + } +} \ No newline at end of file diff --git a/src/NmeaParser/Nmea/Zda.cs b/src/NmeaParser/Nmea/Zda.cs index 0d95eb1..91c306e 100644 --- a/src/NmeaParser/Nmea/Zda.cs +++ b/src/NmeaParser/Nmea/Zda.cs @@ -22,7 +22,7 @@ namespace NmeaParser.Messages ///
[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "Zda")] [NmeaMessageType("--ZDA")] - public class Zda : NmeaMessage + public class Zda : NmeaMessage, ITimestampedMessage { /// /// Initializes a new instance of the class. @@ -53,5 +53,7 @@ namespace NmeaParser.Messages /// Gets the time of fix /// public DateTimeOffset FixDateTime { get; } + + TimeSpan ITimestampedMessage.Timestamp => FixDateTime.TimeOfDay; } } diff --git a/src/NmeaParser/NmeaDevice.cs b/src/NmeaParser/NmeaDevice.cs index c65f493..f45a348 100644 --- a/src/NmeaParser/NmeaDevice.cs +++ b/src/NmeaParser/NmeaDevice.cs @@ -11,52 +11,52 @@ // * See the License for the specific language governing permissions and // * limitations under the License. // ****************************************************************************** - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.IO; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.IO; using System.Threading; using NmeaParser.Messages; -namespace NmeaParser -{ - /// - /// A generic abstract NMEA device - /// - public abstract class NmeaDevice : IDisposable - { - private readonly object m_lockObject = new object(); - private string m_message = ""; - private Stream? m_stream; - private CancellationTokenSource? m_cts; +namespace NmeaParser +{ + /// + /// A generic abstract NMEA device + /// + public abstract class NmeaDevice : IDisposable + { + private readonly object m_lockObject = new object(); + private string m_message = ""; + private Stream? m_stream; + private CancellationTokenSource? m_cts; private bool m_isOpening; private Task? m_ParserTask; - /// - /// Initializes a new instance of the class. - /// - protected NmeaDevice() - { - } - + /// + /// Initializes a new instance of the class. + /// + protected NmeaDevice() + { + } + /// /// Creates and opens the stream the will be working on top off. /// - /// A task that represents the asynchronous action. - public async Task OpenAsync() - { - lock (m_lockObject) - { - if (IsOpen || m_isOpening) return; + /// A task that represents the asynchronous action. + public async Task OpenAsync() + { + lock (m_lockObject) + { + if (IsOpen || m_isOpening) return; m_isOpening = true; - } - m_cts = new CancellationTokenSource(); - m_stream = await OpenStreamAsync(); - StartParser(m_cts.Token); - _lastMultiMessage = null; + } + m_cts = new CancellationTokenSource(); + m_stream = await OpenStreamAsync(); + StartParser(m_cts.Token); + _lastMultiMessage = null; lock (m_lockObject) { IsOpen = true; @@ -64,32 +64,32 @@ namespace NmeaParser } } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1804:RemoveUnusedLocals", MessageId = "_")] - private void StartParser(CancellationToken token) - { + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1804:RemoveUnusedLocals", MessageId = "_")] + private void StartParser(CancellationToken token) + { System.Diagnostics.Debug.WriteLine("Starting parser..."); - m_ParserTask = Task.Run(async () => - { - byte[] buffer = new byte[1024]; - while (!token.IsCancellationRequested) - { - int readCount = 0; - try - { - readCount = await ReadAsync(buffer, 0, 1024, token).ConfigureAwait(false); - } - catch { } - if (token.IsCancellationRequested) - break; - if (readCount > 0) - { - OnData(buffer.Take(readCount).ToArray()); - } - await Task.Delay(50); - } - }); - } - + m_ParserTask = Task.Run(async () => + { + byte[] buffer = new byte[1024]; + while (!token.IsCancellationRequested) + { + int readCount = 0; + try + { + readCount = await ReadAsync(buffer, 0, 1024, token).ConfigureAwait(false); + } + catch { } + if (token.IsCancellationRequested) + break; + if (readCount > 0) + { + OnData(buffer, readCount); + } + await Task.Yield(); + } + }); + } + /// /// Performs a read operation of the stream /// @@ -97,13 +97,13 @@ namespace NmeaParser /// The byte offset in buffer at which to begin writing data from the stream. /// The maximum number of bytes to read. /// The token to monitor for cancellation requests. The default value is System.Threading.CancellationToken.None. - /// - /// A task that represents the asynchronous read operation. The value of the TResult - /// parameter contains the total number of bytes read into the buffer. The result - /// value can be less than the number of bytes requested if the number of bytes currently - /// available is less than the requested number, or it can be 0 (zero) if the end - /// of the stream has been reached. - /// + /// + /// A task that represents the asynchronous read operation. The value of the TResult + /// parameter contains the total number of bytes read into the buffer. The result + /// value can be less than the number of bytes requested if the number of bytes currently + /// available is less than the requested number, or it can be 0 (zero) if the end + /// of the stream has been reached. + /// protected virtual Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { if (m_stream == null) @@ -111,180 +111,182 @@ namespace NmeaParser return m_stream.ReadAsync(buffer, 0, 1024, cancellationToken); } - /// - /// Creates and opens the stream the NmeaDevice is working on top off. - /// - /// The opened data stream. - /// - protected abstract Task OpenStreamAsync(); - - /// - /// Closes the device. - /// - /// A task that represents the asynchronous action. - public async Task CloseAsync() - { - if (m_cts != null) - { - if (m_cts != null) - m_cts.Cancel(); - m_cts = null; - } + /// + /// Creates and opens the stream the NmeaDevice is working on top off. + /// + /// The opened data stream. + /// + protected abstract Task OpenStreamAsync(); + + /// + /// Closes the device. + /// + /// A task that represents the asynchronous action. + public async Task CloseAsync() + { + if (m_cts != null) + { + if (m_cts != null) + m_cts.Cancel(); + m_cts = null; + } if (m_ParserTask != null) - await m_ParserTask; + await m_ParserTask; if (m_stream != null) - await CloseStreamAsync(m_stream); - _lastMultiMessage = null; + await CloseStreamAsync(m_stream); + _lastMultiMessage = null; m_stream = null; lock (m_lockObject) { m_isOpening = false; IsOpen = false; - } - } - /// - /// Closes the stream the NmeaDevice is working on top off. - /// - /// The stream to be closed. + } + } + /// + /// Closes the stream the NmeaDevice is working on top off. + /// + /// The stream to be closed. /// A task that represents the asynchronous action. /// - protected abstract Task CloseStreamAsync(Stream stream); - - private void OnData(byte[] data) - { - var nmea = System.Text.Encoding.UTF8.GetString(data, 0, data.Length); - List lines = new List(); - lock (m_lockObject) - { - m_message += nmea; - - var lineEnd = m_message.IndexOf("\n", StringComparison.Ordinal); - while (lineEnd > -1) - { - string line = m_message.Substring(0, lineEnd).Trim(); - m_message = m_message.Substring(lineEnd + 1); - if (!string.IsNullOrEmpty(line)) - lines.Add(line); - lineEnd = m_message.IndexOf("\n", StringComparison.Ordinal); - } - } - foreach(var line in lines) - ProcessMessage(line); + protected abstract Task CloseStreamAsync(Stream stream); + + private void OnData(byte[] data, int count) + { + var nmea = System.Text.Encoding.UTF8.GetString(data, 0, count); + List lines = new List(); + lock (m_lockObject) + { + m_message += nmea; + + var lineEnd = m_message.IndexOf("\n", StringComparison.Ordinal); + while (lineEnd > -1) + { + string line = m_message.Substring(0, lineEnd).Trim(); + m_message = m_message.Substring(lineEnd + 1); + if (!string.IsNullOrEmpty(line)) + lines.Add(line); + lineEnd = m_message.IndexOf("\n", StringComparison.Ordinal); + } + } + foreach(var line in lines) + ProcessMessage(line); } - private IMultiSentenceMessage? _lastMultiMessage; - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification="Must silently handle invalid/corrupt input")] - private void ProcessMessage(string p) - { - try - { - var msg = NmeaMessage.Parse(p, _lastMultiMessage); + private IMultiSentenceMessage? _lastMultiMessage; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification="Must silently handle invalid/corrupt input")] + private void ProcessMessage(string p) + { + try + { + if (p.Length == 0 || p[0] != '$') + return; + var msg = NmeaMessage.Parse(p, _lastMultiMessage); if(msg is IMultiSentenceMessage multi) { if (!multi.IsComplete) { _lastMultiMessage = multi; //Keep it around until next time return; - } + } } _lastMultiMessage = null; - if (msg != null) - OnMessageReceived(msg); - } - catch { } + if (msg != null) + OnMessageReceived(msg); + } + catch { } } private void OnMessageReceived(NmeaMessage msg) - { - if (msg == null) - return; + { + if (msg == null) + return; MessageReceived?.Invoke(this, new NmeaMessageReceivedEventArgs(msg)); - } - - //private readonly Dictionary> MultiPartMessageCache = new Dictionary>(); - - /// - /// Occurs when an NMEA message is received. - /// - public event EventHandler? MessageReceived; - - /// - /// Releases unmanaged and - optionally - managed resources. - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Releases unmanaged and - optionally - managed resources. - /// - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. - protected virtual void Dispose(bool disposing) - { - if (m_stream != null) - { - if (m_cts != null) - { - m_cts.Cancel(); - m_cts = null; - } - CloseStreamAsync(m_stream); - if (disposing && m_stream != null) - m_stream.Dispose(); - m_stream = null; - } - } - - /// - /// Gets a value indicating whether this device is open. - /// - /// - /// true if this instance is open; otherwise, false. - /// - public bool IsOpen { get; private set; } - + } + + //private readonly Dictionary> MultiPartMessageCache = new Dictionary>(); + + /// + /// Occurs when an NMEA message is received. + /// + public event EventHandler? MessageReceived; + + /// + /// Releases unmanaged and - optionally - managed resources. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases unmanaged and - optionally - managed resources. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + protected virtual void Dispose(bool disposing) + { + if (m_stream != null) + { + if (m_cts != null) + { + m_cts.Cancel(); + m_cts = null; + } + CloseStreamAsync(m_stream); + if (disposing && m_stream != null) + m_stream.Dispose(); + m_stream = null; + } + } + + /// + /// Gets a value indicating whether this device is open. + /// + /// + /// true if this instance is open; otherwise, false. + /// + public bool IsOpen { get; private set; } + /// /// Gets a value indicating whether this device supports writing - /// - /// - public virtual bool CanWrite { get => false; } - + ///
+ /// + public virtual bool CanWrite { get => false; } + /// /// Writes to the device stream. Useful for transmitting RTCM corrections to the device /// Check the property before calling this method. /// - /// The byte array that contains the data to write to the port. - /// The zero-based byte offset in the buffer parameter at which to begin copying - /// bytes to the port. - /// The number of bytes to write. - /// Task - /// + /// The byte array that contains the data to write to the port. + /// The zero-based byte offset in the buffer parameter at which to begin copying + /// bytes to the port. + /// The number of bytes to write. + /// Task + /// public virtual Task WriteAsync(byte[] buffer, int offset, int length) { throw new NotSupportedException(); - } - } - - /// - /// Event argument for the - /// - public sealed class NmeaMessageReceivedEventArgs : EventArgs - { + } + } + + /// + /// Event argument for the + /// + public sealed class NmeaMessageReceivedEventArgs : EventArgs + { internal NmeaMessageReceivedEventArgs(NmeaMessage message) - { - Message = message; - } - - /// - /// Gets the nmea message. - /// - /// - /// The nmea message. - /// + { + Message = message; + } + + /// + /// Gets the nmea message. + /// + /// + /// The nmea message. + /// public NmeaMessage Message { get; } - } -} + } +} diff --git a/src/NmeaParser/NmeaParser.csproj b/src/NmeaParser/NmeaParser.csproj index 34d1c0f..ec372b1 100644 --- a/src/NmeaParser/NmeaParser.csproj +++ b/src/NmeaParser/NmeaParser.csproj @@ -1,7 +1,7 @@  - netstandard1.4;netcoreapp2.1;net451;monoandroid50;monoandroid70;xamarinios10;uap10.0.16299 + netstandard2.0;netstandard1.4;netcoreapp2.1;net451;monoandroid50;monoandroid70;xamarinios10;uap10.0.16299 true true Debug;Release @@ -9,9 +9,9 @@ Morten Nielsen Morten Nielsen An NMEA stream parser for serial port, bluetooth and file-based nmea simulation. - nmea winrt wpf uwp xamarin gps serialport bluetooth + NMEA GPS GNSS Serialport Bluetooth Navigation NTRIP RTCM Galileo GLONASS BeiDou Garmin Trimble SharpGIS.NmeaParser - 2.0 + 2.1 NMEA Parser Apache-2.0 https://dotmorten.github.io/NmeaParser/ @@ -20,7 +20,12 @@ Copyright © Morten Nielsen 2015-2020 $(MSBuildThisFileDirectory)..\..\artifacts\NmeaParser\$(Configuration) ..\..\artifacts\NuGet\$(Configuration)\ - New refined and easier to use v2 API + Added ITimestampedMessage interface to easily get time stamps from any message that supports it. +Added IGeographicLocation interface to easily get any message that supports reporting a location. +Added GnssMonitor for simplifying monitoring location messages and reporting a location. +Added an NTRIP client for getting RTCM correctional messages from an NTRIP Server. +Improved the buffered stream to more accurately emulate baud rates. +All messages now have a unique timestamp useful to expire older messages. true true $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb @@ -30,11 +35,15 @@ enable logo.png + 2.1.0.0 $(DefineConstants);NETSTANDARD + + $(DefineConstants);NETSTANDARD + $(DefineConstants);NETFX @@ -66,7 +75,7 @@ - + diff --git a/src/SampleApp.Droid/Resources/Resource.Designer.cs b/src/SampleApp.Droid/Resources/Resource.Designer.cs index 1084f06..db084bd 100644 --- a/src/SampleApp.Droid/Resources/Resource.Designer.cs +++ b/src/SampleApp.Droid/Resources/Resource.Designer.cs @@ -2,7 +2,6 @@ //------------------------------------------------------------------------------ // // This code was generated by a tool. -// Runtime Version:4.0.30319.42000 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -15,7 +14,7 @@ namespace SampleApp.Droid { - [System.CodeDom.Compiler.GeneratedCodeAttribute("Xamarin.Android.Build.Tasks", "1.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Xamarin.Android.Build.Tasks", "1.0.0.0")] public partial class Resource { diff --git a/src/SampleApp.UWP/SampleApp.UWP.csproj b/src/SampleApp.UWP/SampleApp.UWP.csproj index 6182b26..1fb996d 100644 --- a/src/SampleApp.UWP/SampleApp.UWP.csproj +++ b/src/SampleApp.UWP/SampleApp.UWP.csproj @@ -18,7 +18,8 @@ 512 {A5A43C5B-DE2A-4C0C-9213-0A381AF9435A};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} SampleApp.UWP_TemporaryKey.pfx - D89DCA9AB58E8FD65E1F0E01AF1A7AFC277C1706 + F2A07DA2EDDDF050675AAFAB0507AA6E66A355F0 + True true diff --git a/src/SampleApp.UWP/SampleApp.UWP_TemporaryKey.pfx b/src/SampleApp.UWP/SampleApp.UWP_TemporaryKey.pfx index a2b80d5..b8e6854 100644 Binary files a/src/SampleApp.UWP/SampleApp.UWP_TemporaryKey.pfx and b/src/SampleApp.UWP/SampleApp.UWP_TemporaryKey.pfx differ diff --git a/src/SampleApp.WinDesktop/GgaControl.xaml b/src/SampleApp.WinDesktop/GgaControl.xaml index 25e6c08..1d1d83c 100644 --- a/src/SampleApp.WinDesktop/GgaControl.xaml +++ b/src/SampleApp.WinDesktop/GgaControl.xaml @@ -32,7 +32,7 @@ - + diff --git a/src/SampleApp.WinDesktop/GnssMonitorView.xaml b/src/SampleApp.WinDesktop/GnssMonitorView.xaml new file mode 100644 index 0000000..565f480 --- /dev/null +++ b/src/SampleApp.WinDesktop/GnssMonitorView.xaml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SampleApp.WinDesktop/GnssMonitorView.xaml.cs b/src/SampleApp.WinDesktop/GnssMonitorView.xaml.cs new file mode 100644 index 0000000..6bf6837 --- /dev/null +++ b/src/SampleApp.WinDesktop/GnssMonitorView.xaml.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using NmeaParser.Gnss; +using NmeaParser.Messages; + +namespace SampleApp.WinDesktop +{ + /// + /// Interaction logic for GnssMonitorView.xaml + /// + public partial class GnssMonitorView : UserControl + { + public GnssMonitorView() + { + InitializeComponent(); + } + + public GnssMonitor Monitor + { + get { return (GnssMonitor)GetValue(MonitorProperty); } + set { SetValue(MonitorProperty, value); } + } + + public static readonly DependencyProperty MonitorProperty = + DependencyProperty.Register(nameof(Monitor), typeof(GnssMonitor), typeof(GnssMonitorView), new PropertyMetadata(null, (d,e) => ((GnssMonitorView)d).OnMonitorPropertyChanged(e))); + + private void OnMonitorPropertyChanged(DependencyPropertyChangedEventArgs e) + { + if (e.OldValue is GnssMonitor oldMonitor) + { + oldMonitor.LocationChanged -= LocationChanged; + oldMonitor.LocationLost -= LocationChanged; + } + if (e.NewValue is GnssMonitor newMonitor) + { + newMonitor.LocationChanged += LocationChanged; + newMonitor.LocationLost += LocationChanged; + } + UpdateValues(); + } + + private void LocationChanged(object sender, System.EventArgs e) + { + Dispatcher.Invoke(UpdateValues); + } + private void UpdateValues() + { + if (Monitor == null) + Values.ItemsSource = null; + else + { + var props = Monitor.GetType().GetProperties(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public); + List> values = new List>(); + foreach (var prop in props.OrderBy(t => t.Name)) + { + if (prop.Name == nameof(GnssMonitor.AllMessages)) continue; + if (prop.PropertyType.IsSubclassOf(typeof(NmeaMessage))) + continue; + var value = prop.GetValue(Monitor); + if (!(value is string) && value is System.Collections.IEnumerable arr) + { + var str = "[" + string.Join(",", arr.OfType().ToArray()) + "]"; + if (str.Length == 2) + str = "[ ]"; + value = str; + } + values.Add(new KeyValuePair(prop.Name, value)); + } + Values.ItemsSource = values; + } + } + } +} diff --git a/src/SampleApp.WinDesktop/GsaControl.xaml b/src/SampleApp.WinDesktop/GsaControl.xaml index 0096744..e955509 100644 --- a/src/SampleApp.WinDesktop/GsaControl.xaml +++ b/src/SampleApp.WinDesktop/GsaControl.xaml @@ -12,7 +12,7 @@ - + diff --git a/src/SampleApp.WinDesktop/GsaControl.xaml.cs b/src/SampleApp.WinDesktop/GsaControl.xaml.cs index 5acab7e..3221876 100644 --- a/src/SampleApp.WinDesktop/GsaControl.xaml.cs +++ b/src/SampleApp.WinDesktop/GsaControl.xaml.cs @@ -33,6 +33,14 @@ namespace SampleApp.WinDesktop } public static readonly DependencyProperty MessageProperty = - DependencyProperty.Register(nameof(Message), typeof(Gsa), typeof(GsaControl), new PropertyMetadata(null)); - } + DependencyProperty.Register(nameof(Message), typeof(Gsa), typeof(GsaControl), new PropertyMetadata(null, (d, e) => ((GsaControl)d).OnGsaPropertyChanged(e))); + + private void OnGsaPropertyChanged(DependencyPropertyChangedEventArgs e) + { + if (Message == null) + vehicles.Value = null; + else + vehicles.Value = string.Join(",", Message?.SatelliteIDs); + } + } } diff --git a/src/SampleApp.WinDesktop/MainWindow.xaml b/src/SampleApp.WinDesktop/MainWindow.xaml index 8fdc921..ad24d2e 100644 --- a/src/SampleApp.WinDesktop/MainWindow.xaml +++ b/src/SampleApp.WinDesktop/MainWindow.xaml @@ -3,7 +3,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:SampleApp.WinDesktop" xmlns:esri="http://schemas.esri.com/arcgis/runtime/2013" - Title="Sample App" Height="500" Width="625"> + Title="NMEA Parser Sample App" Height="768" Width="1024">