diff --git a/src/devices/Common/Iot/Device/Common/PositionExtensions.cs b/src/devices/Common/Iot/Device/Common/PositionExtensions.cs
new file mode 100644
index 0000000000..5165512b0d
--- /dev/null
+++ b/src/devices/Common/Iot/Device/Common/PositionExtensions.cs
@@ -0,0 +1,63 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace Iot.Device.Common
+{
+ ///
+ /// Extensions for positions
+ ///
+ public static partial class PositionExtensions
+ {
+ ///
+ /// Normalizes the longitude to +/- 180°
+ ///
+ public static GeographicPosition NormalizeAngleTo180(this GeographicPosition position)
+ {
+ return new GeographicPosition(position.Latitude, NormalizeAngleTo180(position.Longitude), position.EllipsoidalHeight);
+ }
+
+ ///
+ /// Normalizes the angle to +/- 180°
+ ///
+ public static double NormalizeAngleTo180(double angleDegree)
+ {
+ angleDegree %= 360;
+ if (angleDegree <= -180)
+ {
+ angleDegree += 360;
+ }
+ else if (angleDegree > 180)
+ {
+ angleDegree -= 360;
+ }
+
+ return angleDegree;
+ }
+
+ ///
+ /// Normalizes the longitude to [0..360°)
+ ///
+ public static GeographicPosition NormalizeAngleTo360(this GeographicPosition position)
+ {
+ return new GeographicPosition(position.Latitude, NormalizeAngleTo360(position.Longitude), position.EllipsoidalHeight);
+ }
+
+ ///
+ /// Normalizes an angle to [0..360°)
+ ///
+ public static double NormalizeAngleTo360(double angleDegree)
+ {
+ angleDegree %= 360;
+ if (angleDegree < 0)
+ {
+ angleDegree += 360;
+ }
+
+ return angleDegree;
+ }
+ }
+}
diff --git a/src/devices/Common/Iot/Device/Common/SimpleFileLogger.cs b/src/devices/Common/Iot/Device/Common/SimpleFileLogger.cs
new file mode 100644
index 0000000000..a8a134058d
--- /dev/null
+++ b/src/devices/Common/Iot/Device/Common/SimpleFileLogger.cs
@@ -0,0 +1,75 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace Iot.Device.Common
+{
+ ///
+ /// A simple logger that creates textual log files. Created via
+ ///
+ public sealed class SimpleFileLogger : ILogger
+ {
+ private readonly string _category;
+ private TextWriter _writer;
+
+ ///
+ /// Creates a new logger
+ ///
+ /// Logger category name
+ /// The text writer for logging.
+ ///
+ /// The must be a thread-safe file writer!
+ ///
+ public SimpleFileLogger(string category, TextWriter writer)
+ {
+ _category = category;
+ _writer = writer;
+ Enabled = true;
+ }
+
+ ///
+ /// Used by the factory to terminate all its loggers
+ ///
+ internal bool Enabled
+ {
+ get;
+ set;
+ }
+
+ ///
+ /// Does nothing and returns an empty IDisposable
+ ///
+ /// Current logger state
+ /// State argument
+ /// An empty
+ public IDisposable BeginScope(TState state)
+ where TState : notnull
+ {
+ return new LogDispatcher.ScopeDisposable();
+ }
+
+ ///
+ public bool IsEnabled(LogLevel logLevel)
+ {
+ return Enabled;
+ }
+
+ ///
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter)
+ {
+ if (Enabled)
+ {
+ string msg = formatter(state, exception);
+ var time = DateTime.Now;
+ _writer.WriteLine($"{time.ToShortDateString()} {time.ToLongTimeString()} - {_category} - {logLevel} - {msg}");
+ }
+ }
+ }
+}
diff --git a/src/devices/Common/Iot/Device/Common/SimpleFileLoggerFactory.cs b/src/devices/Common/Iot/Device/Common/SimpleFileLoggerFactory.cs
new file mode 100644
index 0000000000..620943e5a3
--- /dev/null
+++ b/src/devices/Common/Iot/Device/Common/SimpleFileLoggerFactory.cs
@@ -0,0 +1,72 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Iot.Device.Common
+{
+ ///
+ /// Provides a very simple console logger that does not require a reference to Microsoft.Extensions.Logging.dll
+ ///
+ public class SimpleFileLoggerFactory : ILoggerFactory, IDisposable
+ {
+ private TextWriter? _writer;
+ private List _createdLoggers;
+
+ ///
+ /// Create a logger factory that creates loggers to logs to the specified file
+ ///
+ /// File name to log to (full path)
+ public SimpleFileLoggerFactory(string fileName)
+ {
+ _writer = TextWriter.Synchronized(new StreamWriter(fileName, true, Encoding.UTF8));
+ _createdLoggers = new List();
+ }
+
+ ///
+ /// The console logger is built-in here
+ ///
+ /// Argument is ignored
+ public void AddProvider(ILoggerProvider provider)
+ {
+ }
+
+ ///
+ public ILogger CreateLogger(string categoryName)
+ {
+ if (_writer == null)
+ {
+ return NullLogger.Instance;
+ }
+
+ var newLogger = new SimpleFileLogger(categoryName, _writer);
+ _createdLoggers.Add(newLogger);
+ return newLogger;
+ }
+
+ ///
+ public void Dispose()
+ {
+ foreach (var d in _createdLoggers)
+ {
+ d.Enabled = false;
+ }
+
+ _createdLoggers.Clear();
+
+ if (_writer != null)
+ {
+ _writer.Close();
+ _writer.Dispose();
+ _writer = null;
+ }
+ }
+ }
+}
diff --git a/src/devices/Mcp23xxx/Mcp23xxx.cs b/src/devices/Mcp23xxx/Mcp23xxx.cs
index 4a1e437b4e..7b169f9912 100644
--- a/src/devices/Mcp23xxx/Mcp23xxx.cs
+++ b/src/devices/Mcp23xxx/Mcp23xxx.cs
@@ -254,11 +254,12 @@ protected override void Dispose(bool disposing)
{
_controller?.Dispose();
_controller = null;
+
+ _pinValues.Clear();
+ _bus?.Dispose();
+ _bus = null!;
}
- _pinValues.Clear();
- _bus?.Dispose();
- _bus = null!;
base.Dispose(disposing);
}
diff --git a/src/devices/Nmea0183/NmeaParser.cs b/src/devices/Nmea0183/NmeaParser.cs
index 17fd1eaf59..f802e5368b 100644
--- a/src/devices/Nmea0183/NmeaParser.cs
+++ b/src/devices/Nmea0183/NmeaParser.cs
@@ -145,6 +145,11 @@ private void Parser()
FireOnParserError(x.Message, NmeaError.PortClosed);
continue;
}
+ catch (OperationCanceledException x)
+ {
+ FireOnParserError(x.Message, NmeaError.PortClosed);
+ continue;
+ }
if (currentLine == null)
{
diff --git a/src/devices/Nmea0183/NmeaUdpServer.cs b/src/devices/Nmea0183/NmeaUdpServer.cs
index 7b0861200f..a48c07012a 100644
--- a/src/devices/Nmea0183/NmeaUdpServer.cs
+++ b/src/devices/Nmea0183/NmeaUdpServer.cs
@@ -25,6 +25,7 @@ public class NmeaUdpServer : NmeaSinkAndSource
{
private readonly int _localPort;
private readonly int _remotePort;
+ private readonly string _broadcastAddress;
private UdpClient? _server;
private NmeaParser? _parser;
@@ -57,10 +58,26 @@ public NmeaUdpServer(string name, int port)
/// The port to receive data on
/// The network port to send data to (must be different than local port when communicating to a local process)
public NmeaUdpServer(string name, int localPort, int remotePort)
+ : this(name, localPort, remotePort, "255.255.255.255")
+ {
+ }
+
+ ///
+ /// Create an UDP server with the given name on the given port, using an alternate outgoing port. The outgoing and incoming
+ /// port may be equal only if the sender and the receiver are not on the same computer.
+ ///
+ /// The network source name
+ /// The port to receive data on
+ /// The network port to send data to (must be different than local port when communicating to a local process)
+ /// Broadcast address of the network interface to use. This is the IP-Address of that interfaces with all
+ /// bits set to 1 that are NOT set in the subnetmask. For a default subnet mask of 255.255.255.0 and a local ip of 192.168.1.45 this is therefore
+ /// 192.168.1.255.
+ public NmeaUdpServer(string name, int localPort, int remotePort, string broadcastAddress)
: base(name)
{
_localPort = localPort;
_remotePort = remotePort;
+ _broadcastAddress = broadcastAddress;
}
///
@@ -89,8 +106,11 @@ public override void StartDecode()
throw new InvalidOperationException("Server already started");
}
- _server = new UdpClient(_localPort);
-
+ _server = new UdpClient();
+ _server.EnableBroadcast = true;
+ _server.Client.Bind(new IPEndPoint(IPAddress.Any, _localPort));
+ // byte[] bytes = Encoding.UTF8.GetBytes("Test message\r\n");
+ // _server.Send(bytes, bytes.Length, "192.168.1.255", _localPort);
if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
// This is unsupported on MacOS (https://github.com/dotnet/runtime/issues/27653), but this shouldn't
@@ -110,7 +130,7 @@ public override void StartDecode()
_server.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReceiveTimeout, 1000);
}
- _clientStream = new UdpClientStream(_server, _localPort, _remotePort, this);
+ _clientStream = new UdpClientStream(_server, _localPort, _remotePort, this, _broadcastAddress);
_parser = new NmeaParser($"{InterfaceName} (Port {_localPort})", _clientStream, _clientStream);
_parser.OnNewSequence += OnSentenceReceivedFromClient;
_parser.OnParserError += ParserOnParserError;
@@ -179,6 +199,7 @@ private sealed class UdpClientStream : Stream, IDisposable
private readonly int _remotePort;
private readonly NmeaUdpServer _parent;
private readonly Queue _data;
+ private readonly string _broadcastAddress;
private object _disposalLock = new object();
@@ -187,11 +208,12 @@ private sealed class UdpClientStream : Stream, IDisposable
private CancellationTokenSource _cancellationSource;
private CancellationToken _cancellationToken;
- public UdpClientStream(UdpClient client, int localPort, int remotePort, NmeaUdpServer parent)
+ public UdpClientStream(UdpClient client, int localPort, int remotePort, NmeaUdpServer parent, string broadcastAddress)
{
_client = client;
_localPort = localPort;
_remotePort = remotePort;
+ _broadcastAddress = broadcastAddress;
_parent = parent;
_data = new Queue();
_knownSenders = new();
@@ -230,7 +252,7 @@ public override int Read(byte[] buffer, int offset, int count)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
-#if NET6_O_OR_GREATER
+#if NET6_0_OR_GREATER
var result = _client.ReceiveAsync(_cancellationToken).GetAwaiter().GetResult();
datagram = result.Buffer;
#else
@@ -362,8 +384,8 @@ public override void Write(byte[] buffer, int offset, int count)
try
{
- IPEndPoint pt = new IPEndPoint(IPAddress.Broadcast, _remotePort);
- _client.Send(tempBuf, count, pt);
+ IPEndPoint pt = new IPEndPoint(IPAddress.Parse(_broadcastAddress), _remotePort);
+ _client.Send(tempBuf, count, pt);
_lastUnsuccessfulSend.Stop();
}
catch (SocketException x)
diff --git a/src/devices/Nmea0183/Sentences/HeadingAndTrackControl.cs b/src/devices/Nmea0183/Sentences/HeadingAndTrackControl.cs
index f8f8fc3878..15e23b3a15 100644
--- a/src/devices/Nmea0183/Sentences/HeadingAndTrackControl.cs
+++ b/src/devices/Nmea0183/Sentences/HeadingAndTrackControl.cs
@@ -99,7 +99,7 @@ public HeadingAndTrackControl(TalkerId talkerId, IEnumerable fields, Dat
// If override is active ("A"), then we treat this as standby
if (manualOverride == "A")
{
- Status = "A";
+ Status = "M";
Valid = true;
}
else
@@ -187,6 +187,24 @@ public HeadingAndTrackControl(TalkerId talkerId, IEnumerable fields, Dat
///
public override bool ReplacesOlderInstance => true;
+ ///
+ /// Returns the status as user-readable string (common name)
+ ///
+ /// Name of the mode
+ public static string UserState(string statusChar)
+ {
+ return statusChar switch
+ {
+ "M" => "Standby",
+ "S" => "Auto",
+ "H" => "External",
+ "T" => "Track",
+ "R" => "Remote",
+ "W" => "Wind",
+ _ => "Unknown",
+ };
+ }
+
///
public override string ToNmeaParameterList()
{
@@ -224,7 +242,7 @@ public override string ToNmeaParameterList()
///
public override string ToReadableContent()
{
- return $"Mode: {Status}, CommandedTrack: {CommandedTrack}, TurnMode: {TurnMode}";
+ return $"Autopilot command: {UserState(Status)}, CommandedTrack: {CommandedTrack}, TurnMode: {TurnMode}";
}
///
@@ -232,7 +250,7 @@ public override string ToReadableContent()
///
/// Input angle
/// An angle or null, if the input is null
- protected static Angle? AsAngle(double? value)
+ internal static Angle? AsAngle(double? value)
{
if (value.HasValue)
{
@@ -247,7 +265,7 @@ public override string ToReadableContent()
///
/// Angle to translate
/// The translated angle or just a comma
- protected static string FromAngle(Angle? angle)
+ internal static string FromAngle(Angle? angle)
{
if (!angle.HasValue)
{
@@ -259,7 +277,7 @@ protected static string FromAngle(Angle? angle)
}
}
- private static Length? AsLength(double? value)
+ internal static Length? AsLength(double? value)
{
if (value.HasValue)
{
@@ -269,7 +287,7 @@ protected static string FromAngle(Angle? angle)
return null;
}
- private static string FromLength(Length? length)
+ internal static string FromLength(Length? length)
{
if (length.HasValue == false)
{
diff --git a/src/devices/Nmea0183/Sentences/HeadingAndTrackControlStatus.cs b/src/devices/Nmea0183/Sentences/HeadingAndTrackControlStatus.cs
index 868f9bb90b..2dcaf2d039 100644
--- a/src/devices/Nmea0183/Sentences/HeadingAndTrackControlStatus.cs
+++ b/src/devices/Nmea0183/Sentences/HeadingAndTrackControlStatus.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
@@ -13,12 +14,12 @@ namespace Iot.Device.Nmea0183.Sentences
///
/// This is the status reply from the autopilot. It is similar to the HTC message with 4 extra fields.
///
- public class HeadingAndTrackControlStatus : HeadingAndTrackControl
+ public class HeadingAndTrackControlStatus : NmeaSentence
{
///
/// This sentence's id
///
- public static new SentenceId Id => new SentenceId("HTD");
+ public static SentenceId Id => new SentenceId("HTD");
private static bool Matches(SentenceId sentence) => Id == sentence;
@@ -48,9 +49,21 @@ public HeadingAndTrackControlStatus(string status, Angle? commandedRudderAngle,
Angle? rudderLimit, Angle? offHeadingLimit, Length? turnRadius, RotationalSpeed? rateOfTurn, Angle? desiredHeading,
Length? offTrackLimit, Angle? commandedTrack, bool headingIsTrue,
bool rudderLimitExceeded, bool headingLimitExceeded, bool trackLimitExceeded, Angle? actualHeading)
- : base(status, commandedRudderAngle, commandedRudderDirection, turnMode, rudderLimit, offHeadingLimit, turnRadius,
- rateOfTurn, desiredHeading, offTrackLimit, commandedTrack, headingIsTrue)
+ : base(OwnTalkerId, Id, DateTimeOffset.UtcNow)
{
+ Valid = true;
+ Status = status;
+ DesiredHeading = desiredHeading;
+ CommandedRudderAngle = commandedRudderAngle;
+ HeadingIsTrue = headingIsTrue;
+ CommandedRudderDirection = commandedRudderDirection;
+ TurnMode = turnMode;
+ RudderLimit = rudderLimit;
+ OffHeadingLimit = offHeadingLimit;
+ TurnRadius = turnRadius;
+ RateOfTurn = rateOfTurn;
+ OffTrackLimit = offTrackLimit;
+ CommandedTrack = commandedTrack;
RudderLimitExceeded = rudderLimitExceeded;
HeadingLimitExceeded = headingLimitExceeded;
TrackLimitExceeded = trackLimitExceeded;
@@ -61,7 +74,7 @@ public HeadingAndTrackControlStatus(string status, Angle? commandedRudderAngle,
/// Internal constructor
///
public HeadingAndTrackControlStatus(TalkerSentence sentence, DateTimeOffset time)
- : base(sentence, time)
+ : this(sentence.TalkerId, Matches(sentence) ? sentence.Fields : throw new ArgumentException($"SentenceId does not match expected id '{Id}'"), time)
{
}
@@ -69,20 +82,121 @@ public HeadingAndTrackControlStatus(TalkerSentence sentence, DateTimeOffset time
/// Decoding constructor
///
public HeadingAndTrackControlStatus(TalkerId talkerId, IEnumerable fields, DateTimeOffset time)
- : base(talkerId, fields, time)
+ : base(talkerId, Id, time)
{
IEnumerator field = fields.GetEnumerator();
- for (int i = 0; i < 13; i++)
+ string manualOverride = ReadString(field);
+ CommandedRudderAngle = HeadingAndTrackControl.AsAngle(ReadValue(field));
+ CommandedRudderDirection = ReadString(field);
+
+ string autoPilotMode = ReadString(field);
+ TurnMode = ReadString(field);
+ RudderLimit = HeadingAndTrackControl.AsAngle(ReadValue(field));
+ OffHeadingLimit = HeadingAndTrackControl.AsAngle(ReadValue(field));
+ TurnRadius = HeadingAndTrackControl.AsLength(ReadValue(field));
+ double? turnRate = ReadValue(field);
+ RateOfTurn = turnRate.HasValue ? RotationalSpeed.FromDegreesPerSecond(turnRate.Value) : null;
+ DesiredHeading = HeadingAndTrackControl.AsAngle(ReadValue(field));
+ OffTrackLimit = HeadingAndTrackControl.AsLength(ReadValue(field));
+ CommandedTrack = HeadingAndTrackControl.AsAngle(ReadValue(field));
+ string headingReference = ReadString(field);
+
+ // If override is active ("A"), then we treat this as standby
+ if (manualOverride == "A")
+ {
+ Status = "M";
+ Valid = true;
+ }
+ else
{
- field.MoveNext();
+ // It appears that on the NMEA2000 side, various proprietary messages are also used to control the autopilot,
+ // hence this is missing some states, such as Wind mode.
+ Status = autoPilotMode;
+ Valid = true;
}
+ HeadingIsTrue = headingReference == "T";
+
RudderLimitExceeded = ReadString(field) == "V";
HeadingLimitExceeded = ReadString(field) == "V";
TrackLimitExceeded = ReadString(field) == "V";
- ActualHeading = AsAngle(ReadValue(field));
+ ActualHeading = HeadingAndTrackControl.AsAngle(ReadValue(field));
}
+ ///
+ /// Autopilot status. Known values:
+ /// M = Manual
+ /// S = Stand-alone heading control
+ /// H = Heading control with external source
+ /// T = Track control
+ /// R = Direct rudder control
+ /// Anything else = ???
+ ///
+ public string Status { get; private set; }
+
+ ///
+ /// Heading to steer.
+ ///
+ public Angle? DesiredHeading { get; private set; }
+
+ ///
+ /// Angle for directly controlling the rudder. Unsigned. (See )
+ ///
+ public Angle? CommandedRudderAngle { get; private set; }
+
+ ///
+ /// True if all angles are true, otherwise false.
+ ///
+ public bool HeadingIsTrue { get; private set; }
+
+ ///
+ /// Commanded rudder direction "L" or "R" for port/starboard.
+ ///
+ public string CommandedRudderDirection { get; private set; }
+
+ ///
+ /// Turn mode (probably only valid for very expensive autopilots)
+ /// Known values:
+ /// R = Radius controlled
+ /// T = Turn rate controlled
+ /// N = Neither
+ ///
+ public string TurnMode { get; private set; }
+
+ ///
+ /// Maximum rudder angle
+ ///
+ public Angle? RudderLimit { get; private set; }
+
+ ///
+ /// Maximum off-heading limit (in heading control mode)
+ ///
+ public Angle? OffHeadingLimit { get; private set; }
+
+ ///
+ /// Desired turn Radius (when is "R")
+ ///
+ public Length? TurnRadius { get; private set; }
+
+ ///
+ /// Desired turn rate (when is "T")
+ /// Base unit is degrees/second
+ ///
+ public RotationalSpeed? RateOfTurn { get; private set; }
+
+ ///
+ /// Off-track warning limit, unsigned.
+ ///
+ public Length? OffTrackLimit { get; private set; }
+
+ ///
+ /// Commanded track
+ ///
+ public Angle? CommandedTrack { get; private set; }
+
+ ///
+ public override bool ReplacesOlderInstance => true;
+
///
/// True if the rudder limit is exceeded
///
@@ -106,9 +220,44 @@ public HeadingAndTrackControlStatus(TalkerId talkerId, IEnumerable field
///
public override string ToNmeaParameterList()
{
- string ret = base.ToNmeaParameterList();
- var angleString = FromAngle(ActualHeading).Replace(",", string.Empty); // the last comma is not needed
- return ret + FormattableString.Invariant($",{(RudderLimitExceeded ? "V" : "A")},{(HeadingLimitExceeded ? "V" : "A")},{(TrackLimitExceeded ? "V" : "A")},{angleString}");
+ if (!Valid)
+ {
+ return string.Empty;
+ }
+
+ StringBuilder b = new StringBuilder();
+ b.Append(Status == "M" ? "A," : "V,");
+ b.Append(HeadingAndTrackControl.FromAngle(CommandedRudderAngle));
+ b.Append(CommandedRudderDirection + ",");
+ b.Append(Status + ",");
+ b.Append(TurnMode + ",");
+ b.Append(HeadingAndTrackControl.FromAngle(RudderLimit));
+ b.Append(HeadingAndTrackControl.FromAngle(OffHeadingLimit));
+ b.Append(HeadingAndTrackControl.FromLength(TurnRadius));
+ if (RateOfTurn.HasValue)
+ {
+ b.Append(RateOfTurn.Value.DegreesPerSecond.ToString("F1", CultureInfo.InvariantCulture) + ",");
+ }
+ else
+ {
+ b.Append(',');
+ }
+
+ b.Append(HeadingAndTrackControl.FromAngle(DesiredHeading));
+ b.Append(HeadingAndTrackControl.FromLength(OffTrackLimit));
+ b.Append(HeadingAndTrackControl.FromAngle(CommandedTrack));
+ b.Append(HeadingIsTrue ? "T" : "M");
+
+ string angleString = HeadingAndTrackControl.FromAngle(ActualHeading).Replace(",", string.Empty); // the last comma is not needed
+ b.Append(($",{(RudderLimitExceeded ? "V" : "A")},{(HeadingLimitExceeded ? "V" : "A")},{(TrackLimitExceeded ? "V" : "A")},{angleString}"));
+
+ return b.ToString();
+ }
+
+ ///
+ public override string ToReadableContent()
+ {
+ return $"Autopilot status: {Status}, Autopilot Heading: {ActualHeading}, CommandedTrack: {CommandedTrack}, TurnMode: {TurnMode}";
}
}
}
diff --git a/src/devices/Nmea0183/Sentences/NmeaSentence.cs b/src/devices/Nmea0183/Sentences/NmeaSentence.cs
index 5bac28e4eb..db5a0cbd0e 100644
--- a/src/devices/Nmea0183/Sentences/NmeaSentence.cs
+++ b/src/devices/Nmea0183/Sentences/NmeaSentence.cs
@@ -165,7 +165,7 @@ protected static DateTimeOffset ParseDateTime(string date, string time)
}
else
{
- d1 = DateTimeOffset.Now.Date;
+ d1 = DateTimeOffset.UtcNow.Date;
}
return new DateTimeOffset(d1.Year, d1.Month, d1.Day, t1.Hours, t1.Minutes, t1.Seconds, t1.Milliseconds, gregorianCalendar, TimeSpan.Zero);
diff --git a/src/devices/Nmea0183/Sentences/RudderSensorAngle.cs b/src/devices/Nmea0183/Sentences/RudderSensorAngle.cs
index 07ab2e4fa0..975ff78d30 100644
--- a/src/devices/Nmea0183/Sentences/RudderSensorAngle.cs
+++ b/src/devices/Nmea0183/Sentences/RudderSensorAngle.cs
@@ -100,10 +100,10 @@ public override string ToNmeaParameterList()
if (Valid)
{
StringBuilder sb = new StringBuilder();
- sb.Append(Starboard.ToString("F1", CultureInfo.InvariantCulture) + ",A,");
+ sb.Append(Starboard.Degrees.ToString("F1", CultureInfo.InvariantCulture) + ",A,");
if (Port.HasValue)
{
- sb.Append(Port.Value.ToString("F1", CultureInfo.InvariantCulture) + ",A");
+ sb.Append(Port.Value.Degrees.ToString("F1", CultureInfo.InvariantCulture) + ",A");
}
else
{
diff --git a/src/devices/Nmea0183/TalkerSentence.cs b/src/devices/Nmea0183/TalkerSentence.cs
index 698d25394a..ef74120248 100644
--- a/src/devices/Nmea0183/TalkerSentence.cs
+++ b/src/devices/Nmea0183/TalkerSentence.cs
@@ -50,6 +50,8 @@ public class TalkerSentence
knownSentences[HeadingAndTrackControl.Id] = (sentence, time) => new HeadingAndTrackControl(sentence, time);
knownSentences[SeatalkNmeaMessage.Id] = (sentence, time) => new SeatalkNmeaMessage(sentence, time);
knownSentences[RudderSensorAngle.Id] = (sentence, time) => new RudderSensorAngle(sentence, time);
+ knownSentences[HeadingAndTrackControl.Id] = (sentence, time) => new HeadingAndTrackControl(sentence, time);
+ knownSentences[HeadingAndTrackControlStatus.Id] = (sentence, time) => new HeadingAndTrackControlStatus(sentence, time);
knownSentences[ProprietaryMessage.Id] = (sentence, time) =>
{
var specificMessageId = sentence.Fields.FirstOrDefault();
diff --git a/src/devices/Nmea0183/samples/NmeaSimulator/NmeaSimulator.cs b/src/devices/Nmea0183/samples/NmeaSimulator/NmeaSimulator.cs
index 5ed63a9f50..b105e45387 100644
--- a/src/devices/Nmea0183/samples/NmeaSimulator/NmeaSimulator.cs
+++ b/src/devices/Nmea0183/samples/NmeaSimulator/NmeaSimulator.cs
@@ -3,10 +3,12 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Net;
using System.Linq;
+using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Threading;
using Iot.Device;
@@ -16,6 +18,7 @@
using Iot.Device.Seatalk1;
using UnitsNet;
using CommandLine;
+using Microsoft.Extensions.Logging;
namespace Nmea.Simulator
{
@@ -64,6 +67,20 @@ public static int Main(string[] args)
return 1;
}
+ if (parsed.Value.Debug)
+ {
+ Console.WriteLine("Waiting for debugger...");
+ while (!Debugger.IsAttached)
+ {
+ Thread.Sleep(100);
+ }
+ }
+
+ if (parsed.Value.Verbose)
+ {
+ LogDispatcher.LoggerFactory = new SimpleConsoleLoggerFactory(LogLevel.Trace);
+ }
+
var sim = new Simulator();
if (!string.IsNullOrWhiteSpace(parsed.Value.ReplayFiles))
{
@@ -102,9 +119,44 @@ private void StartServer(string seatalk)
_tcpServer.StartDecode();
_tcpServer.OnNewSequence += OnNewSequenceFromServer;
+ // This code block tries to determine the broadcast address of the local master ethernet adapter.
+ // This needs adjustment if the main adapter is a WIFI port.
+ string broadcastAddress = "255.255.255.255";
+ var interfaces = NetworkInterface.GetAllNetworkInterfaces();
+ foreach (var a in interfaces)
+ {
+ if (a.OperationalStatus == OperationalStatus.Up && a.NetworkInterfaceType == NetworkInterfaceType.Ethernet)
+ {
+ var properties = a.GetIPProperties();
+ foreach (var unicast in properties.UnicastAddresses)
+ {
+ if (unicast.Address.AddressFamily == AddressFamily.InterNetwork)
+ {
+ byte[] ipBytes = unicast.Address.GetAddressBytes();
+ // A virtual address usually looks like a router address, that means it's last part is .1
+ if (ipBytes[3] == 1)
+ {
+ continue;
+ }
+
+ byte[] maskBytes = unicast.IPv4Mask.GetAddressBytes();
+ for (int i = 0; i < ipBytes.Length; i++)
+ {
+ // Make all bits 1 that are NOT set in the mask
+ ipBytes[i] |= (byte)~maskBytes[i];
+ }
+
+ broadcastAddress = new IPAddress(ipBytes).ToString();
+ }
+ }
+
+ break;
+ }
+ }
+
// Outgoing port is 10110, the incoming port is irrelevant (but we choose it differently here, so that a
// receiver can bind to 10110 on the same computer)
- _udpServer = new NmeaUdpServer("UdpServer", 10111, 10110);
+ _udpServer = new NmeaUdpServer("UdpServer", 10110, 10110, broadcastAddress);
_udpServer.StartDecode();
_udpServer.OnNewSequence += OnNewSequenceFromServer;
@@ -205,6 +257,15 @@ private void SendNewData()
SeaSmartEngineDetail detail = new SeaSmartEngineDetail(engineData);
SendSentence(detail);
+ GeographicPosition target = new GeographicPosition(47.54, 9.48, 0);
+ GreatCircle.DistAndDir(data.Position, target, out var distance, out var direction);
+ Speed vmg = Math.Cos(AngleExtensions.Difference(data.Course, direction).Radians) * data.SpeedOverGround;
+ Length xtError = Length.FromNauticalMiles(0.2);
+ var rmb = new RecommendedMinimumNavToDestination(zda.DateTime, xtError, "Start", "FH", target, distance, direction, vmg, false);
+ SendSentence(rmb);
+
+ var xte = new CrossTrackError(xtError);
+ SendSentence(xte);
// Test Seatalk message (understood by some OpenCPN plugins)
////RawSentence sentence = new RawSentence(new TalkerId('S', 'T'), new SentenceId("ALK"), new string[]
////{
diff --git a/src/devices/Nmea0183/samples/NmeaSimulator/SimulatorArguments.cs b/src/devices/Nmea0183/samples/NmeaSimulator/SimulatorArguments.cs
index e628762962..757081effd 100644
--- a/src/devices/Nmea0183/samples/NmeaSimulator/SimulatorArguments.cs
+++ b/src/devices/Nmea0183/samples/NmeaSimulator/SimulatorArguments.cs
@@ -26,5 +26,19 @@ public string SeatalkInterface
get;
set;
}
+
+ [Option("debug", Default = false, HelpText = "Wait for debugger on startup")]
+ public bool Debug
+ {
+ get;
+ set;
+ }
+
+ [Option('v', "verbose", Default = false, HelpText = "Show verbose log messages")]
+ public bool Verbose
+ {
+ get;
+ set;
+ }
}
}
diff --git a/src/devices/Nmea0183/tests/SentenceTests.cs b/src/devices/Nmea0183/tests/SentenceTests.cs
index 4072d73678..4b0d278dd2 100644
--- a/src/devices/Nmea0183/tests/SentenceTests.cs
+++ b/src/devices/Nmea0183/tests/SentenceTests.cs
@@ -505,6 +505,32 @@ public void HtcEncode()
hdt = new HeadingAndTrackControl("H", Angle.FromDegrees(10.21), "L", "N", null, null, Length.FromNauticalMiles(22.29), null, null, null, Angle.FromDegrees(2), false);
msg = hdt.ToNmeaParameterList();
Assert.Equal("V,10.2,L,H,N,,,22.3,,,,2.0,M", msg);
+
+ var sentence = TalkerSentence.FromSentenceString("$GPHTC,V,10.2,L,H,N,,,22.3,,,,2.0,M", out var error);
+ Assert.Equal(NmeaError.None, error);
+ DateTimeOffset time = DateTimeOffset.UtcNow;
+ var hdt2 = (HeadingAndTrackControl)sentence!.TryGetTypedValue(ref time)!;
+ Assert.Equal(hdt.ToNmeaParameterList(), hdt2.ToNmeaParameterList());
+ }
+
+ [Fact]
+ public void HtdEncode()
+ {
+ var hdt = new HeadingAndTrackControlStatus("M", null, "L", "N", null, null, null, null, null, null, null, true, false, false, false, Angle.FromDegrees(10.12));
+ var msg = hdt.ToNmeaParameterList();
+ Assert.Equal("A,,L,M,N,,,,,,,,T,A,A,A,10.1", msg);
+
+ hdt = new HeadingAndTrackControlStatus("H", Angle.FromDegrees(10.21), "L", "N", null, null, Length.FromNauticalMiles(22.29), null, null, null, Angle.FromDegrees(2), false, true, true, true, Angle.FromDegrees(12.23));
+ msg = hdt.ToNmeaParameterList();
+ Assert.Equal("V,10.2,L,H,N,,,22.3,,,,2.0,M,V,V,V,12.2", msg);
+
+ var sentence = TalkerSentence.FromSentenceString("$GPHTD,V,10.2,L,H,N,,,22.3,,,,2.0,M,V,V,V,12.23", out var error);
+ Assert.Equal(NmeaError.None, error);
+ DateTimeOffset time = DateTimeOffset.UtcNow;
+ var hdt2 = (HeadingAndTrackControlStatus)sentence!.TryGetTypedValue(ref time)!;
+ Assert.Equal(hdt.ToNmeaParameterList(), hdt2.ToNmeaParameterList());
+ Assert.Equal(HeadingAndTrackControlStatus.Id, hdt.SentenceId);
+ Assert.Equal(HeadingAndTrackControlStatus.Id, hdt2.SentenceId);
}
[Fact]
@@ -600,6 +626,8 @@ public void SentenceRoundTrip(string input)
[InlineData("$YDVHW,,T,,M,3.1,N,5.7,K,*64")]
[InlineData("$YDMWD,336.8,T,333.8,M,21.6,N,11.1,M*58")]
[InlineData("$APRSA,12.2,A,,V")]
+ [InlineData("$GPHTD,V,10.2,L,H,N,,,22.3,,,,2.0,M,V,V,V,12.23")]
+ [InlineData("$GPHTC,V,10.2,L,H,N,,,22.3,,,,2.0,M")]
public void CanParseAllTheseMessages(string input)
{
var inSentence = TalkerSentence.FromSentenceString(input, out var error);
@@ -633,6 +661,8 @@ public void CanParseAllTheseMessages(string input)
[InlineData("$APHTD,V,10.0,L,R,N,12.0,13.5,2.0,1.0,15.1,0.5,16.2,T,V,A,V,123.2")]
[InlineData("$STALK,84,86,26,97,02,00,00,00,08")]
[InlineData("$STALK,9C,01,12,00")]
+ [InlineData("$GPHTD,V,10.2,L,H,N,,,22.3,,,,2.0,M,V,V,V,12.2")]
+ [InlineData("$GPHTC,V,10.2,L,H,N,,,22.3,,,,2.0,M")]
public void SentenceRoundTripIsUnaffectedByCulture(string input)
{
// de-DE has "," as decimal separator. Big trouble if using CurrentCulture for any parsing or formatting here
diff --git a/src/devices/Seatalk1/AutoPilotRemoteController.cs b/src/devices/Seatalk1/AutoPilotRemoteController.cs
index f6ad1ad39e..9b06b94f0b 100644
--- a/src/devices/Seatalk1/AutoPilotRemoteController.cs
+++ b/src/devices/Seatalk1/AutoPilotRemoteController.cs
@@ -25,7 +25,7 @@ namespace Iot.Device.Seatalk1
///
public class AutoPilotRemoteController : MarshalByRefObject
{
- private const double AngleEpsilon = 1.1; // The protocol can only give angles in whole degrees
+ private const double AngleEpsilon = 0.9; // The protocol can only give angles in whole degrees
private static readonly TimeSpan MaximumTimeout = TimeSpan.FromSeconds(6);
private readonly SeatalkInterface _parentInterface;
private readonly object _lock = new object();
@@ -249,6 +249,7 @@ public bool SetStatus(AutopilotStatus newStatus, TimeSpan timeout, ref TurnDirec
{
if (Status == newStatus && CourseComputerStatus == 0)
{
+ _logger.LogInformation("Not setting status {NewStatus} because already set.", newStatus);
return true; // nothing to do
}
@@ -266,12 +267,15 @@ public bool SetStatus(AutopilotStatus newStatus, TimeSpan timeout, ref TurnDirec
_ => throw new ArgumentException($"Status {newStatus} is not valid", nameof(newStatus)),
};
+ _logger.LogInformation("Setting status {Status} by pressing button(s) {Button}", newStatus, buttonToPress);
+
// For setting wind or track modes, we need to first set auto mode.
// Setting wind mode without auto works (and is returned as status 0x4), but has no visible effect.
if (newStatus == AutopilotStatus.Wind || newStatus == AutopilotStatus.Track)
{
if (!SendMessageAndVerifyStatus(new Keystroke(AutopilotButtons.Auto, 1), timeout, () => Status is AutopilotStatus.Auto or AutopilotStatus.Wind or AutopilotStatus.Track))
{
+ _logger.LogInformation($"Could not transition from Auto to {newStatus} mode");
return false;
}
}
@@ -310,6 +314,15 @@ public bool SetStatus(AutopilotStatus newStatus, TimeSpan timeout, ref TurnDirec
ret = Status == AutopilotStatus.Track && CourseComputerStatus == CourseComputerWarnings.None;
}
+ if (ret)
+ {
+ _logger.LogInformation($"Status {Status} set successfully");
+ }
+ else
+ {
+ _logger.LogError("Status was not set correctly");
+ }
+
return ret;
}
@@ -478,17 +491,7 @@ internal bool AnglesAreClose(Angle angle1, Angle angle2)
return true;
}
- if (angle1 >= Angle.FromDegrees(359) && angle2 <= Angle.FromDegrees(1))
- {
- return true;
- }
-
- if (angle2 >= Angle.FromDegrees(359) && angle1 <= Angle.FromDegrees(1))
- {
- return true;
- }
-
- return false;
+ return UnitMath.Abs(AngleExtensions.Difference(angle1, angle2)) < Angle.FromDegrees(1.5);
}
///
@@ -525,7 +528,7 @@ private bool SendMessageAndVerifyStatus(SeatalkMessage message, TimeSpan timeout
{
if (timeout > MaximumTimeout)
{
- throw new ArgumentOutOfRangeException(nameof(timeout), $"The maximum timeout is {MaximumTimeout}, see remarks in documentation");
+ throw new ArgumentOutOfRangeException(nameof(timeout), $"The maximum timeout is {MaximumTimeout}, see remarks on AutoPilotRemoteController.SetStatus in the documentation");
}
_buttonOnApPressed = false;
@@ -554,9 +557,11 @@ internal void UpdateStatus()
{
lock (_lock)
{
- if (_lastUpdateTime + TimeSpan.FromSeconds(5) < DateTime.UtcNow)
+ // Netstandard 2.0 doesn't support the multiply operator on timespan
+ TimeSpan twice = new TimeSpan(DefaultTimeout.Ticks * 2);
+ if (_lastUpdateTime + twice < DateTime.UtcNow)
{
- // The autopilot hasn't sent anything for 5 seconds. Assume it's offline
+ // The autopilot hasn't sent anything for several seconds. Assume it's offline
if (Status != AutopilotStatus.Offline) // don't repeat message
{
_logger.LogWarning("Autopilot connection timed out. Assuming it's offline");
diff --git a/src/devices/Seatalk1/Messages/AutopilotWindStatus.cs b/src/devices/Seatalk1/Messages/AutopilotWindStatus.cs
new file mode 100644
index 0000000000..c8358698b0
--- /dev/null
+++ b/src/devices/Seatalk1/Messages/AutopilotWindStatus.cs
@@ -0,0 +1,65 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Iot.Device.Seatalk1.Messages
+{
+ ///
+ /// ST-2000 forwards this message when it receives the NMEA wind sequences.
+ /// The exact meaning is unknown as of yet.
+ /// Format:
+ /// 11-80-0X, where X is a number between 0 and 4, note that the encodes as 11-01-XX-0Y
+ ///
+ public record AutopilotWindStatus : SeatalkMessage
+ {
+ ///
+ /// Constructs a new instance of this class
+ ///
+ public AutopilotWindStatus()
+ {
+ Status = 0;
+ }
+
+ ///
+ /// Constructs a new instance
+ ///
+ /// The status to report
+ public AutopilotWindStatus(int status)
+ {
+ Status = status;
+ }
+
+ ///
+ /// The status byte (meaning unknown)
+ ///
+ public int Status
+ {
+ get;
+ }
+
+ ///
+ public override byte CommandByte => 0x11;
+
+ ///
+ public override byte ExpectedLength => 0x03;
+
+ ///
+ public override SeatalkMessage CreateNewMessage(IReadOnlyList data)
+ {
+ int status = data[2];
+ return new AutopilotWindStatus(status);
+ }
+
+ ///
+ public override byte[] CreateDatagram()
+ {
+ int status = Status & 0xFF;
+ return new byte[] { CommandByte, 0, (byte)status, };
+ }
+ }
+}
diff --git a/src/devices/Seatalk1/Messages/CompassHeadingAutopilotCourse.cs b/src/devices/Seatalk1/Messages/CompassHeadingAutopilotCourse.cs
index e2372164a0..cbd432de2e 100644
--- a/src/devices/Seatalk1/Messages/CompassHeadingAutopilotCourse.cs
+++ b/src/devices/Seatalk1/Messages/CompassHeadingAutopilotCourse.cs
@@ -14,8 +14,7 @@
namespace Iot.Device.Seatalk1.Messages
{
///
- /// Compass heading and current autopilot course and parameters. This message contains the same fields as ,
- /// plus some more.
+ /// Compass heading and current autopilot course and parameters.
///
public record CompassHeadingAutopilotCourse : SeatalkMessage
{
@@ -80,6 +79,10 @@ public override SeatalkMessage CreateNewMessage(IReadOnlyList data)
{
Logger.LogWarning($"Unknown autopilot status byte {data[4]}");
}
+ else
+ {
+ Logger.LogInformation($"Current autopilot status: {status}");
+ }
sbyte rudder = (sbyte)data[6];
diff --git a/src/devices/Seatalk1/Messages/SpeedTroughWater.cs b/src/devices/Seatalk1/Messages/SpeedTroughWater.cs
new file mode 100644
index 0000000000..6b1d6d7b10
--- /dev/null
+++ b/src/devices/Seatalk1/Messages/SpeedTroughWater.cs
@@ -0,0 +1,73 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Generic;
+using UnitsNet;
+
+namespace Iot.Device.Seatalk1.Messages
+{
+ ///
+ /// Speed trough water (data from log instrument)
+ ///
+ public record SpeedTroughWater : SeatalkMessage
+ {
+ internal SpeedTroughWater()
+ {
+ Speed = Speed.FromKnots(0); // Because the decoding converts to knots
+ }
+
+ ///
+ /// Constructs a new instance
+ ///
+ /// The current speed
+ public SpeedTroughWater(Speed speed)
+ {
+ Speed = speed;
+ Forwarded = false;
+ }
+
+ ///
+ /// The current speed trough water
+ ///
+ public Speed Speed
+ {
+ get;
+ }
+
+ ///
+ /// True if the message was forwarded from somewhere
+ ///
+ public bool Forwarded
+ {
+ get;
+ init;
+ }
+
+ ///
+ public override byte CommandByte => 0x20;
+
+ ///
+ public override byte ExpectedLength => 0x4;
+
+ ///
+ public override SeatalkMessage CreateNewMessage(IReadOnlyList data)
+ {
+ double speedvalue = data[2] << 8 | data[3];
+ speedvalue /= 10.0;
+ return new SpeedTroughWater(Speed.FromKnots(speedvalue))
+ {
+ Forwarded = (data[1] & 0x80) != 0
+ };
+ }
+
+ ///
+ public override byte[] CreateDatagram()
+ {
+ double v = Speed.Knots * 10.0;
+ int v1 = (int)v;
+ byte b1 = (byte)(v1 >> 8);
+ byte b2 = (byte)(v1 & 0xff);
+ return new byte[] { CommandByte, (byte)(Forwarded ? 0x81 : 0x1), b1, b2 };
+ }
+ }
+}
diff --git a/src/devices/Seatalk1/Messages/TargetWaypointName.cs b/src/devices/Seatalk1/Messages/TargetWaypointName.cs
new file mode 100644
index 0000000000..db77666b17
--- /dev/null
+++ b/src/devices/Seatalk1/Messages/TargetWaypointName.cs
@@ -0,0 +1,119 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Iot.Device.Seatalk1.Messages
+{
+ ///
+ /// This message reports the last 4 characters of the next waypoint
+ ///
+ public record TargetWaypointName : SeatalkMessage
+ {
+ internal TargetWaypointName()
+ {
+ Name = "0000";
+ }
+
+ ///
+ /// Creates a new instance of this class
+ ///
+ /// The name of the waypoint. Only the last 4 chars will be transmitted.
+ public TargetWaypointName(string name)
+ {
+ Name = name;
+ }
+
+ ///
+ public override byte CommandByte => 0x82;
+
+ ///
+ public override byte ExpectedLength => 8;
+
+ ///
+ /// The name of the waypoint. Only the last 4 characters are transmitted. If the string is shorter than 4, it is padded with zeros.
+ ///
+ public string Name
+ {
+ get;
+ private set;
+ }
+
+ ///
+ public override SeatalkMessage CreateNewMessage(IReadOnlyList data)
+ {
+ // 82 05 XX xx YY yy ZZ zz Target waypoint name, with XX+yy == 0xff etc.
+ int c1, c2, c3, c4;
+ c1 = data[2] & 0x3F;
+ c2 = ((data[4] & 0xF) * 4) + ((data[2] & 0xC0) / 64);
+ c3 = ((data[6] & 0x3) * 16) + ((data[4] & 0xF0) / 16);
+ c4 = (data[6] & 0xFC) / 4;
+ c1 += 0x30;
+ c2 += 0x30;
+ c3 += 0x30;
+ c4 += 0x30;
+ string name = $"{(char)c1}{(char)c2}{(char)c3}{(char)c4}";
+ return new TargetWaypointName(name);
+ }
+
+ ///
+ public override byte[] CreateDatagram()
+ {
+ string chars;
+ if (Name.Length > 4)
+ {
+ chars = Name.Substring(Name.Length - 4, 4);
+ }
+ else
+ {
+ chars = Name.Substring(0, Name.Length);
+ chars = chars + new String('0', 4 - Name.Length);
+ }
+
+ var result = new byte[]
+ {
+ CommandByte, (byte)(ExpectedLength - 3), 0x0, 0xff, 0x0, 0xff, 0x0, 0xff
+ };
+
+ int c1 = ConvertLetter(chars[0]);
+ int c2 = ConvertLetter(chars[1]);
+ int c3 = ConvertLetter(chars[2]);
+ int c4 = ConvertLetter(chars[3]);
+
+ result[2] = (byte)((c1) + (c2 << 6));
+ result[4] = (byte)((c2 / 4) + (c3 << 4));
+ result[6] = (byte)((c3 >> 4) + (c4 << 2));
+
+ result[3] = (byte)(0xff - result[2]);
+ result[5] = (byte)(0xff - result[4]);
+ result[7] = (byte)(0xff - result[6]);
+
+ return result;
+ }
+
+ private int ConvertLetter(char c)
+ {
+ c = Char.ToUpper(c, CultureInfo.InvariantCulture);
+ // We have 6 bit per char, and the ascii table is offset by 0x30 (which is the digit "0")
+ // The last letter that could be represented is the small "o", but I'm not sure anything above "Z" (0x5a) is valid.
+ if (c < 0x30 || (c - 0x30) > 0x3F)
+ {
+ return 0; // We cannot use this character, replace with "0"
+ }
+
+ return c - 0x30;
+ }
+
+ ///
+ public override bool MatchesMessageType(IReadOnlyList data)
+ {
+ // The data bytes 2-7 must pairwise sum to 0xff, otherwise the message is corrupted.
+ return base.MatchesMessageType(data) && (data[2] + data[3]) == 0xFF && (data[4] + data[5]) == 0xFF && (data[6] + data[7]) == 0xFF;
+ }
+ }
+}
diff --git a/src/devices/Seatalk1/Seatalk1.csproj b/src/devices/Seatalk1/Seatalk1.csproj
index 3d0adbedd7..3d5d94dcd9 100644
--- a/src/devices/Seatalk1/Seatalk1.csproj
+++ b/src/devices/Seatalk1/Seatalk1.csproj
@@ -20,6 +20,7 @@
+
@@ -32,6 +33,8 @@
+
+
diff --git a/src/devices/Seatalk1/Seatalk1Parser.cs b/src/devices/Seatalk1/Seatalk1Parser.cs
index 10a0eb8104..4642173281 100644
--- a/src/devices/Seatalk1/Seatalk1Parser.cs
+++ b/src/devices/Seatalk1/Seatalk1Parser.cs
@@ -65,6 +65,9 @@ public Seatalk1Parser(Stream inputStream)
new ApparentWindSpeed(),
new NavigationToWaypoint(),
new CourseComputerStatus(),
+ new TargetWaypointName(),
+ new AutopilotWindStatus(),
+ new SpeedTroughWater(),
};
MaxMessageLength = _messageFactories.Select(x => x.ExpectedLength).Max();
@@ -194,8 +197,13 @@ private void Parser()
}
else
{
- var bytesFound = BitConverter.ToString(_buffer.ToArray());
- _logger.LogWarning($"Seatalk parser sync lost. Buffer contents: {bytesFound}, trying to resync");
+ if (isInSync)
+ {
+ var bytesFound = BitConverter.ToString(_buffer.ToArray());
+ _logger.LogWarning(
+ $"Seatalk parser sync lost. Buffer contents: {bytesFound}, trying to resync");
+ }
+
_buffer.RemoveAt(0);
// We removed the first byte from the sequence, we need to try again before we add the next byte
isInSync = false;
diff --git a/src/devices/Seatalk1/SeatalkInterface.cs b/src/devices/Seatalk1/SeatalkInterface.cs
index bbe7e19007..27922a44a3 100644
--- a/src/devices/Seatalk1/SeatalkInterface.cs
+++ b/src/devices/Seatalk1/SeatalkInterface.cs
@@ -202,23 +202,30 @@ public virtual bool SendDatagram(byte[] data)
}
// Only one thread at a time, please!
- lock (_lock)
+ try
{
- foreach (var e in dataWithParity)
+ lock (_lock)
{
- // The many flushes here appear to be necessary to make sure the parity correctly applies to the next byte we send (and only to the next)
- if (_port.Parity != e.P)
+ foreach (var e in dataWithParity)
{
- _port.BaseStream.Flush();
- _port.Parity = e.P;
- _port.BaseStream.Flush();
+ // The many flushes here appear to be necessary to make sure the parity correctly applies to the next byte we send (and only to the next)
+ if (_port.Parity != e.P)
+ {
+ _port.BaseStream.Flush();
+ _port.Parity = e.P;
+ _port.BaseStream.Flush();
+ }
+
+ _port.Write(data, e.Index, 1);
}
- _port.Write(data, e.Index, 1);
+ _port.BaseStream.Flush();
+ _port.Parity = DefaultParity;
}
-
- _port.BaseStream.Flush();
- _port.Parity = DefaultParity;
+ }
+ catch (InvalidOperationException)
+ {
+ return false;
}
return true;
diff --git a/src/devices/Seatalk1/SeatalkNmeaMessageWithDecoding.cs b/src/devices/Seatalk1/SeatalkNmeaMessageWithDecoding.cs
new file mode 100644
index 0000000000..376e66c2b4
--- /dev/null
+++ b/src/devices/Seatalk1/SeatalkNmeaMessageWithDecoding.cs
@@ -0,0 +1,46 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Iot.Device.Nmea0183;
+using Iot.Device.Nmea0183.Sentences;
+using Iot.Device.Seatalk1.Messages;
+
+namespace Iot.Device.Seatalk1
+{
+ ///
+ /// Similar to it's base class, but transports the original message so that it can be printed or interpreted directly.
+ ///
+ /// This is necessary because at least in split build mode NMEA classes can't reference Seatalk messages
+ public class SeatalkNmeaMessageWithDecoding : SeatalkNmeaMessage
+ {
+ private SeatalkMessage _decodedMessage;
+
+ ///
+ /// Constructs a new instance of this message
+ ///
+ /// The binary datagram of the seatalk message
+ /// The decoded seatalk message
+ /// The event time
+ public SeatalkNmeaMessageWithDecoding(byte[] datagram, SeatalkMessage decodedMessage, DateTimeOffset time)
+ : base(datagram, time)
+ {
+ _decodedMessage = decodedMessage ?? throw new ArgumentNullException(nameof(decodedMessage));
+ }
+
+ ///
+ /// Returns the decoded Seatalk message
+ ///
+ public SeatalkMessage SourceMessage => _decodedMessage;
+
+ ///
+ public override string ToReadableContent()
+ {
+ return _decodedMessage.ToString() ?? base.ToReadableContent();
+ }
+ }
+}
diff --git a/src/devices/Seatalk1/SeatalkToNmeaConverter.cs b/src/devices/Seatalk1/SeatalkToNmeaConverter.cs
index a7ad728676..a1bc6df553 100644
--- a/src/devices/Seatalk1/SeatalkToNmeaConverter.cs
+++ b/src/devices/Seatalk1/SeatalkToNmeaConverter.cs
@@ -2,7 +2,9 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
+using System.Threading;
using Iot.Device.Common;
using Iot.Device.Nmea0183;
using Iot.Device.Nmea0183.Sentences;
@@ -25,6 +27,9 @@ public class SeatalkToNmeaConverter : NmeaSinkAndSource
private SeatalkInterface _seatalkInterface;
private bool _isDisposed;
private ILogger _logger;
+ private BlockingCollection _sendQueue;
+ private Thread? _sendThread;
+ private CancellationTokenSource _terminatingCancellationTokenSource;
///
/// Construct an instance of this class.
@@ -34,6 +39,8 @@ public class SeatalkToNmeaConverter : NmeaSinkAndSource
public SeatalkToNmeaConverter(string interfaceName, string portName)
: base(interfaceName)
{
+ _terminatingCancellationTokenSource = new CancellationTokenSource();
+ _sendQueue = new BlockingCollection();
_sentencesToTranslate = new();
_logger = this.GetCurrentClassLogger();
_seatalkInterface = new SeatalkInterface(portName);
@@ -50,12 +57,13 @@ public SeatalkToNmeaConverter(string interfaceName, string portName)
/// RSA (Seatalk->Nmea)
/// MWV (Nmea->Seatalk)
/// RMB (Nmea->Seatalk)
+ /// HTC (Nmea->Seatalk, translated into commands)
///
public List SentencesToTranslate => _sentencesToTranslate;
private void SeatalkMessageReceived(SeatalkMessage stalk)
{
- var nmeaMsg = new SeatalkNmeaMessage(stalk.CreateDatagram(), DateTimeOffset.UtcNow);
+ var nmeaMsg = new SeatalkNmeaMessageWithDecoding(stalk.CreateDatagram(), stalk, DateTimeOffset.UtcNow);
_logger.LogDebug($"Received Seatalk message: {stalk}");
DispatchSentenceEvents(nmeaMsg);
@@ -97,9 +105,29 @@ public override void StartDecode()
throw new ObjectDisposedException(nameof(SeatalkToNmeaConverter));
}
+ _sendThread = new Thread(ProcessSendQueue);
+ _sendThread.Start();
_seatalkInterface.StartDecode();
}
+ private void ProcessSendQueue()
+ {
+ try
+ {
+ while (!_terminatingCancellationTokenSource.IsCancellationRequested)
+ {
+ if (_sendQueue.TryTake(out var item, -1, _terminatingCancellationTokenSource.Token))
+ {
+ item();
+ }
+ }
+ }
+ catch (OperationCanceledException x)
+ {
+ _logger.LogDebug(x, "Send task terminating, caught OperationCancelledException");
+ }
+ }
+
///
/// Send a sentence through the Seatalk port. This ignores unknown or disabled sentences.
///
@@ -126,6 +154,7 @@ public override void SendSentence(NmeaSinkAndSource source, NmeaSentence sentenc
}
}
+ // Translations NMEA->Seatalk
if (DoTranslate(sentence, out WindSpeedAndAngle? mwv) && mwv != null)
{
ApparentWindAngle awa = new ApparentWindAngle()
@@ -147,6 +176,59 @@ public override void SendSentence(NmeaSinkAndSource source, NmeaSentence sentenc
NavigationToWaypoint nwp = new NavigationToWaypoint(rmb.CrossTrackError, rmb.BearingToWayPoint, true, rmb.DistanceToWayPoint);
_seatalkInterface.SendMessage(nwp);
}
+
+ if (DoTranslate(sentence, out HeadingAndTrackControl? htc) && htc != null)
+ {
+ // Empty the queue when this (rare) message arrives, so we can directly issue its commands
+ while (_sendQueue.TryTake(out var dispose, TimeSpan.FromSeconds(0.5)))
+ {
+ // Wait
+ }
+
+ var ap = _seatalkInterface.GetAutopilotRemoteController();
+
+ AutopilotStatus desiredStatus = htc.Status switch
+ {
+ "M" => AutopilotStatus.Standby,
+ "S" => AutopilotStatus.Auto,
+ "W" => AutopilotStatus.Wind,
+ "T" => AutopilotStatus.Track,
+
+ _ => AutopilotStatus.Undefined,
+ };
+
+ _sendQueue.Add(() =>
+ {
+ TurnDirection? confirm = null;
+
+ if (desiredStatus == AutopilotStatus.Track)
+ {
+ if (!ap.SetStatus(desiredStatus, ref confirm))
+ {
+ ap.SetStatus(desiredStatus, ref confirm);
+ }
+ }
+ else
+ {
+ ap.SetStatus(desiredStatus, ref confirm);
+ }
+
+ if (ap.IsOperating && htc.DesiredHeading.HasValue)
+ {
+ ap.TurnTo(htc.DesiredHeading.Value, null);
+ }
+ });
+
+ if (ap.DeadbandMode == DeadbandMode.Automatic && htc.OffHeadingLimit.HasValue && htc.OffHeadingLimit.Value.Equals(Angle.Zero, Angle.FromDegrees(0.5)))
+ {
+ _sendQueue.Add(() => ap.SetDeadbandMode(DeadbandMode.Minimal));
+ }
+
+ if (ap.DeadbandMode == DeadbandMode.Minimal && htc.OffHeadingLimit.HasValue && !htc.OffHeadingLimit.Value.Equals(Angle.Zero, Angle.FromDegrees(0.5)))
+ {
+ _sendQueue.Add(() => ap.SetDeadbandMode(DeadbandMode.Automatic));
+ }
+ }
}
private bool DoTranslate(NmeaSentence sentence, out T? convertedSentence)
@@ -167,7 +249,24 @@ private bool DoTranslate(NmeaSentence sentence, out T? convertedSentence)
///
public override void StopDecode()
{
- _seatalkInterface.Dispose();
+ _terminatingCancellationTokenSource.Cancel();
+ if (!_isDisposed)
+ {
+ if (!_sendQueue.IsAddingCompleted)
+ {
+ _sendQueue.CompleteAdding();
+ }
+
+ if (_sendThread != null)
+ {
+ _sendThread.Join();
+ _sendThread = null;
+ }
+
+ _sendQueue.Dispose();
+ _seatalkInterface.Dispose();
+ }
+
_isDisposed = true;
}
}
diff --git a/src/devices/Seatalk1/tests/AutoPilotRemoteControllerTests.cs b/src/devices/Seatalk1/tests/AutoPilotRemoteControllerTests.cs
index 7187f97ec5..7777f14702 100644
--- a/src/devices/Seatalk1/tests/AutoPilotRemoteControllerTests.cs
+++ b/src/devices/Seatalk1/tests/AutoPilotRemoteControllerTests.cs
@@ -102,6 +102,19 @@ public void TurnToHasNoEffectWhenStandby()
Assert.False(_testee.TurnTo(Angle.FromDegrees(100), TurnDirection.Starboard));
}
+ [Fact]
+ public void ReturnsToOfflineAfterTimeout()
+ {
+ SetToStandby();
+ Assert.Equal(AutopilotStatus.Standby, _testee.Status);
+ _testee.UpdateStatus();
+ Assert.Equal(AutopilotStatus.Standby, _testee.Status);
+ _testee.DefaultTimeout = TimeSpan.FromSeconds(0);
+ Thread.Sleep(100);
+ _testee.UpdateStatus();
+ Assert.Equal(AutopilotStatus.Offline, _testee.Status);
+ }
+
[Fact]
public void TurnToStarboard()
{
diff --git a/src/devices/Seatalk1/tests/MessageTests.cs b/src/devices/Seatalk1/tests/MessageTests.cs
index c49043f35a..cb48b34fcf 100644
--- a/src/devices/Seatalk1/tests/MessageTests.cs
+++ b/src/devices/Seatalk1/tests/MessageTests.cs
@@ -7,6 +7,7 @@
using System.Linq;
using System.Text;
using System.Threading.Tasks;
+using Iot.Device.Nmea0183.Sentences;
using Iot.Device.Seatalk1;
using Iot.Device.Seatalk1.Messages;
using UnitsNet;
@@ -32,6 +33,8 @@ public MessageTests()
[InlineData("10 01 00 01", typeof(ApparentWindAngle))]
[InlineData("85 06 00 00 C0 0D 1F 00 E0", typeof(NavigationToWaypoint))]
[InlineData("11 01 00 00", typeof(ApparentWindSpeed))]
+ [InlineData("82 05 00 ff 00 ff 00 ff", typeof(TargetWaypointName))]
+ [InlineData("20 01 00 12", typeof(SpeedTroughWater))]
public void KnownMessageTypeDecode(string msg, Type expectedType)
{
msg = msg.Replace(" ", string.Empty);
@@ -189,5 +192,107 @@ public void WindSpeedMessage()
var reverse = windSpeed.CreateNewMessage(data);
Assert.Equal(windSpeed, reverse);
}
+
+ [Fact]
+ public void TargetWaypointInvalidSentence()
+ {
+ byte[] data =
+ {
+ 0x82, 0x05, 0x00, 0xff, 0x00, 0xff, 0x01, 0xff
+ };
+
+ var actualType = _parser.GetTypeOfNextMessage(data, out int length)!;
+ Assert.Null(actualType);
+ }
+
+ [Fact]
+ public void TargetWaypointNameDecode1()
+ {
+ byte[] data =
+ {
+ 0x82, 0x05, 0x00, 0xFF, 0x70, 0x8F, 0x05, 0xFA
+ };
+
+ var actualType = _parser.GetTypeOfNextMessage(data, out int length)!;
+ Assert.NotNull(actualType);
+ var decoded = (TargetWaypointName)actualType.CreateNewMessage(data);
+ Assert.NotNull(decoded);
+ Assert.Equal("00G1", decoded.Name);
+ }
+
+ [Fact]
+ public void TargetWaypointNameDecode2()
+ {
+ byte[] data =
+ {
+ 0x82, 0x05, 0xAA, 0x55, 0x27, 0xd8, 0xA1, 0x5e
+ };
+
+ var actualType = _parser.GetTypeOfNextMessage(data, out int length)!;
+ Assert.NotNull(actualType);
+ var decoded = (TargetWaypointName)actualType.CreateNewMessage(data);
+ Assert.NotNull(decoded);
+ Assert.Equal("ZNBX", decoded.Name);
+ }
+
+ [Fact]
+ public void TargetWaypointRoundTrip1()
+ {
+ byte[] data =
+ {
+ 0x82, 0x05, 0xAA, 0x55, 0x27, 0xd8, 0xA1, 0x5e
+ };
+
+ var actualType = _parser.GetTypeOfNextMessage(data, out int length)!;
+ Assert.NotNull(actualType);
+ var decoded = (TargetWaypointName)actualType.CreateNewMessage(data);
+ Assert.NotNull(decoded);
+
+ var dataEncoded = decoded.CreateDatagram();
+ Assert.Equal(data, dataEncoded);
+ }
+
+ [Theory]
+ [InlineData("AAAA", "AAAA")]
+ [InlineData("", "0000")]
+ [InlineData("Z", "Z000")]
+ [InlineData("abcd", "ABCD")]
+ [InlineData("A_Long_Name2", "AME2")]
+ public void TargetWaypointRoundTrip2(string input, string expected)
+ {
+ TargetWaypointName t1 = new TargetWaypointName(input);
+ var data = t1.CreateDatagram();
+ TargetWaypointName t2 = (TargetWaypointName)t1.CreateNewMessage(data);
+ Assert.Equal(expected, t2.Name);
+ }
+
+ [Fact]
+ public void SpeedTroughWater1()
+ {
+ SpeedTroughWater stw = new SpeedTroughWater(Speed.FromKnots(5.2));
+ var data = stw.CreateDatagram();
+ SpeedTroughWater stw2 = (SpeedTroughWater)stw.CreateNewMessage(data);
+ Assert.False(stw.Forwarded);
+ Assert.True(stw.Speed.Equals(Speed.FromKnots(5.2), Speed.FromKnots(0.1)));
+ Assert.Equal(stw, stw2);
+ }
+
+ [Fact]
+ public void CompassHeadingAutopilotCourse1()
+ {
+ CompassHeadingAutopilotCourse hdg = new CompassHeadingAutopilotCourse()
+ {
+ Alarms = AutopilotAlarms.None,
+ AutoPilotCourse = Angle.FromDegrees(124),
+ AutopilotStatus = AutopilotStatus.Auto,
+ AutoPilotType = 0,
+ CompassHeading = Angle.FromDegrees(220),
+ RudderPosition = Angle.FromDegrees(-10),
+ TurnDirection = TurnDirection.Port,
+ };
+ var data = hdg.CreateDatagram();
+ CompassHeadingAutopilotCourse hdg2 = (CompassHeadingAutopilotCourse)hdg.CreateNewMessage(data);
+ Assert.Equal(hdg, hdg2);
+ }
}
}