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); + } } }