File: SentenceCache.cs
Web Access
Project: ..\..\..\src\Iot.Device.Bindings\Iot.Device.Bindings.csproj (Iot.Device.Bindings)
// 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.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
using Iot.Device.Common;
using Iot.Device.Nmea0183.Sentences;
using Microsoft.Extensions.Logging;
using UnitsNet;
 
namespace Iot.Device.Nmea0183
{
    /// <summary>
    /// Caches the last sentence(s) of each type for later retrieval.
    /// This is a helper class for <see cref="AutopilotController"/> and <see cref="PositionProvider"/>. Use <see cref="PositionProvider"/> to query the position from
    /// the most appropriate messages.
    /// </summary>
    public sealed class SentenceCache : IDisposable
    {
        private readonly NmeaSinkAndSource _source;
        private readonly object _lock;
 
        private readonly Dictionary<int, NmeaSentence> _dinData;
        private readonly Dictionary<SentenceId, NmeaSentence> _sentences;
        private readonly Dictionary<String, Dictionary<SentenceId, NmeaSentence>> _sentencesBySource;
 
        private readonly ILogger _logger;
 
        private Queue<RoutePart> _lastRouteSentences;
        private Dictionary<string, Waypoint> _wayPoints;
        private Queue<SatellitesInView> _lastSatelliteInfos;
        private Dictionary<string, TransducerDataSet> _xdrData;
 
        private SentenceId[] _groupSentences = new SentenceId[]
        {
            // These sentences come in groups or carry multiple different data sets
            new SentenceId("GSV"),
            new SentenceId("RTE"),
            new SentenceId("WPL"),
            new SentenceId("DIN"),
            new SentenceId("XDR"),
        };
 
        /// <summary>
        /// Creates an new cache using the given source
        /// </summary>
        /// <param name="source">The source to monitor</param>
        public SentenceCache(NmeaSinkAndSource source)
        {
            _source = source;
            _lock = new object();
            _sentences = new Dictionary<SentenceId, NmeaSentence>();
            _sentencesBySource = new Dictionary<String, Dictionary<SentenceId, NmeaSentence>>();
            _lastRouteSentences = new Queue<RoutePart>();
            _lastSatelliteInfos = new Queue<SatellitesInView>();
            _wayPoints = new Dictionary<string, Waypoint>();
            _xdrData = new Dictionary<string, TransducerDataSet>();
            _dinData = new Dictionary<int, NmeaSentence>();
            StoreRawSentences = false;
            _logger = this.GetCurrentClassLogger();
            _source.OnNewSequence += OnNewSequence;
        }
 
        /// <summary>
        /// True to (also) store raw sentences. Otherwise only recognized decoded sentences are stored.
        /// Defaults to false.
        /// </summary>
        public bool StoreRawSentences
        {
            get;
            set;
        }
 
        /// <summary>
        /// Clears the cache
        /// </summary>
        public void Clear()
        {
            lock (_lock)
            {
                _sentences.Clear();
                _lastRouteSentences.Clear();
                _wayPoints.Clear();
                _lastSatelliteInfos.Clear();
                _sentencesBySource.Clear();
            }
        }
 
        /// <summary>
        /// Gets the last sentence of the given type.
        /// Does not return sentences that are part of a group (i.e. GSV, RTE)
        /// </summary>
        /// <param name="id">Sentence Id to query</param>
        /// <returns>The last sentence of that type, or null.</returns>
        public NmeaSentence? GetLastSentence(SentenceId id)
        {
            lock (_lock)
            {
                if (_sentences.TryGetValue(id, out var sentence))
                {
                    return sentence;
                }
 
                return null;
            }
        }
 
        /// <summary>
        /// Gets the last sentence with the given id from the given talker.
        /// </summary>
        /// <param name="source">Source to query</param>
        /// <param name="id">Id to query</param>
        /// <returns>The last sentence of that type and source, null if not found</returns>
        public NmeaSentence? GetLastSentence(string? source, SentenceId id)
        {
            if (source == null)
            {
                return GetLastSentence(id);
            }
 
            lock (_lock)
            {
                if (_sentencesBySource.TryGetValue(source, out var list))
                {
                    if (list.TryGetValue(id, out var sentence))
                    {
                        return sentence;
                    }
                }
 
                return null;
            }
        }
 
        /// <summary>
        /// Tries to get a sentence of the given type
        /// </summary>
        /// <typeparam name="T">The type of the sentence to query</typeparam>
        /// <param name="id">The sentence id for T</param>
        /// <param name="sentence">Receives the sentence, if any was found</param>
        /// <returns>True on success, false if no such message was received</returns>
        public bool TryGetLastSentence<T>(SentenceId id,
#if NET5_0_OR_GREATER
            [NotNullWhen(true)]
#endif
            out T sentence)
            where T : NmeaSentence
        {
            var s = GetLastSentence(id);
            if (s is T)
            {
                sentence = (T)s;
                return true;
            }
 
            sentence = null!;
            return false;
        }
 
        /// <summary>
        /// Gets the last sentence of the given type.
        /// Does not return sentences that are part of a group (i.e. GSV, RTE)
        /// </summary>
        /// <param name="id">Sentence Id to query</param>
        /// <param name="maxAge">Maximum age of the sentence</param>
        /// <returns>The last sentence of that type, or null if none was received within the given timespan.</returns>
        public NmeaSentence? GetLastSentence(SentenceId id, TimeSpan maxAge)
        {
            lock (_lock)
            {
                if (_sentences.TryGetValue(id, out var sentence))
                {
                    if (sentence.Age < maxAge)
                    {
                        return sentence;
                    }
                }
 
                return null;
            }
        }
 
        private void OnNewSequence(NmeaSinkAndSource? source, NmeaSentence sentence)
        {
            // Cache only valid sentences
            if (!sentence.Valid)
            {
                return;
            }
 
            if (!StoreRawSentences && sentence is RawSentence)
            {
                return;
            }
 
            string sourceName;
            if (source == null)
            {
                sourceName = MessageRouter.LocalMessageSource;
                _logger.LogWarning($"Cache got message without source: {sentence.ToNmeaMessage()}");
            }
            else
            {
                sourceName = source.InterfaceName;
            }
 
            lock (_lock)
            {
                if (!_groupSentences.Contains(sentence.SentenceId))
                {
                    // Standalone sequences. Only the last message needs to be kept
                    _sentences[sentence.SentenceId] = sentence;
 
                    // We already own the lock to do that a bit more complex update.
                    if (_sentencesBySource.TryGetValue(sourceName, out var dict))
                    {
                        dict[sentence.SentenceId] = sentence;
                    }
                    else
                    {
                        var d = new Dictionary<SentenceId, NmeaSentence>();
                        d[sentence.SentenceId] = sentence;
                        _sentencesBySource[sourceName] = d;
                    }
                }
                else if (sentence.SentenceId == RoutePart.Id && (sentence is RoutePart rte))
                {
                    _lastRouteSentences.Enqueue(rte);
                    while (_lastRouteSentences.Count > 100)
                    {
                        // Throw away old entry
                        _lastRouteSentences.Dequeue();
                    }
                }
                else if (sentence.SentenceId == Waypoint.Id && (sentence is Waypoint wpt))
                {
                    // No reason to clean this up, this will never grow larger than a few hundred entries
                    _wayPoints[wpt.Name] = wpt;
                }
                else if (sentence.SentenceId == SatellitesInView.Id && (sentence is SatellitesInView gsv))
                {
                    _lastSatelliteInfos.Enqueue(gsv);
                    while (_lastSatelliteInfos.Count > 20)
                    {
                        // Throw away old entry
                        _lastSatelliteInfos.Dequeue();
                    }
                }
                else if (sentence.SentenceId == TransducerMeasurement.Id && (sentence is TransducerMeasurement xdr))
                {
                    foreach (var measurement in xdr.DataSets)
                    {
                        _xdrData[measurement.DataName] = measurement;
                    }
                }
                else if (sentence.SentenceId == ProprietaryMessage.Id && (sentence is ProprietaryMessage din))
                {
                    _dinData[din.Identifier] = din;
                }
            }
        }
 
        /// <summary>
        /// Clean up everything
        /// </summary>
        public void Dispose()
        {
            _source.OnNewSequence -= OnNewSequence;
            _sentences.Clear();
        }
 
        /// <summary>
        /// Adds the given sentence to the cache - if manual filling is preferred
        /// </summary>
        /// <param name="sentence">Sentence to add</param>
        public void Add(NmeaSentence sentence)
        {
            OnNewSequence(null, sentence);
        }
 
        /// <summary>
        /// Tries to get a DIN sentence type
        /// </summary>
        /// <typeparam name="T">The type of the sentence to query</typeparam>
        /// <param name="hexId">The hexadecimal identifier for this sub-message</param>
        /// <param name="sentence">Receives the sentence, if any was found</param>
        /// <returns>True on success, false if no such message was received</returns>
        public bool TryGetLastDinSentence<T>(int hexId,
#if NET5_0_OR_GREATER
            [NotNullWhen(true)]
#endif
            out T sentence)
            where T : NmeaSentence
        {
            // The second condition should always be true, because this list only contains din messages
            if (!_dinData.TryGetValue(hexId, out var s) || s.SentenceId != ProprietaryMessage.Id)
            {
                sentence = null!;
                return false;
            }
 
            if (s is T)
            {
                sentence = (T)s;
                return true;
            }
 
            sentence = null!;
            return false;
        }
 
        /// <summary>
        /// Gets the last transducer data set (from an XDR sentence, see <see cref="TransducerMeasurement"/>) if one with the given name exists.
        /// </summary>
        /// <param name="name">The name of the data set. Case sensitive</param>
        /// <param name="data">Returns the value if it exists</param>
        /// <returns>True if a value with the given name was found, false otherwise</returns>
        public bool TryGetTransducerData(string name,
#if NET5_0_OR_GREATER
            [NotNullWhen(true)]
#endif
            out TransducerDataSet data)
        {
            lock (_lock)
            {
                if (_xdrData.TryGetValue(name, out var data1))
                {
                    data = data1;
                    return true;
                }
            }
 
            data = null!;
            return false;
        }
 
        /// <summary>
        /// Queries the waypoint with the given name
        /// </summary>
        /// <param name="name">The name of the waypoint</param>
        /// <param name="wp">The return data</param>
        /// <returns>True if found, false otherwise</returns>
        public bool TryGetWayPoint(string name,
#if NET5_0_OR_GREATER
            [NotNullWhen(true)]
#endif
            out Waypoint wp)
        {
            lock (_lock)
            {
                if (_wayPoints.TryGetValue(name, out var innerResult))
                {
                    wp = innerResult!;
                    return true;
                }
 
                wp = null!;
                return false;
            }
        }
 
        /// <summary>
        /// Returns the last RTE sentences received, to construct the active route.
        /// A set of at most 100 elements is returned, with the newest entry first.
        /// </summary>
        /// <param name="routeParts">The list of RTE sentences</param>
        /// <returns>True if a list was found, false if no RTE messages where received</returns>
        public bool QueryActiveRouteSentences(out List<RoutePart> routeParts)
        {
            List<RoutePart> routeSentences;
            lock (_lock)
            {
                // Newest shall be first in list
                routeSentences = _lastRouteSentences.ToList();
                routeSentences.Reverse();
            }
 
            routeParts = routeSentences;
            return routeSentences.Any();
        }
 
        /// <summary>
        /// Returns a list of recently received <see cref="SatellitesInView"/> (GSV) messages.
        /// </summary>
        /// <param name="sats">The result</param>
        /// <returns>True if the list was non-empty</returns>
        public bool QuerySatellitesInView(out List<SatellitesInView> sats)
        {
            lock (_lock)
            {
                sats = _lastSatelliteInfos.ToList();
                sats.Reverse();
                return sats.Count > 0;
            }
        }
    }
}