using Nonemm.Core; namespace Nonemm.Spotting; /// The stations on the band, in frequency order. A spot ages out after the /// timeout because a bandmap is a picture of the last hour or so, and a stale /// spot costs a move to an empty frequency. /// /// Age is counted from when the spot arrived, not from the time written in it. /// A node with a wrong clock, or one replaying its backlog, would otherwise /// empty the bandmap as fast as it filled it. /// /// Spots arrive on the cluster's thread and are read on the window's, so every /// method locks. The `Changed` event is raised outside the lock, on whichever /// thread made the change. public sealed class Bandmap { private readonly Dictionary spots = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary arrived = new(StringComparer.OrdinalIgnoreCase); private readonly Lock guard = new(); private readonly Func clock; public Bandmap(TimeSpan? lifetime = null, Func? clock = null) { Lifetime = lifetime ?? TimeSpan.FromHours(1); this.clock = clock ?? (() => DateTime.UtcNow); } /// How long a spot stays on the map. N1MM asks for the same number in /// minutes, on the telnet window's Filters tab. public TimeSpan Lifetime { get; set; } public event EventHandler? Changed; public void Add(Spot spot) { string key = Key(spot); lock (guard) { spots[key] = spot; arrived[key] = clock(); } Changed?.Invoke(this, EventArgs.Empty); } public void Remove(Callsign call, Band band) { string key = $"{call.Text}|{band.Name}"; bool removed; lock (guard) { arrived.Remove(key); removed = spots.Remove(key); } if (removed) { Changed?.Invoke(this, EventArgs.Empty); } } public void DropOlderThan(DateTime nowUtc) { int dropped; lock (guard) { List stale = spots .Where(pair => nowUtc - arrived.GetValueOrDefault(pair.Key, nowUtc) > Lifetime) .Select(pair => pair.Key) .ToList(); foreach (string key in stale) { spots.Remove(key); arrived.Remove(key); } dropped = stale.Count; } if (dropped > 0) { Changed?.Invoke(this, EventArgs.Empty); } } public IReadOnlyList On(Band band) { lock (guard) { return spots.Values .Where(s => s.Band == band) .OrderBy(s => s.Frequency.Hertz) .ToList(); } } public IReadOnlyList All() { lock (guard) { return spots.Values.OrderBy(s => s.Frequency.Hertz).ToList(); } } /// The spot nearest the frequency, within the window either side. Used when /// the radio lands on a spot so the entry window can fill the call in. public Spot? Near(Frequency frequency, Frequency window) { lock (guard) { Spot? best = null; long bestDistance = long.MaxValue; foreach (Spot spot in spots.Values) { long distance = Math.Abs(spot.Frequency.Hertz - frequency.Hertz); if (distance <= window.Hertz && distance < bestDistance) { best = spot; bestDistance = distance; } } return best; } } /// One spot per station per band: a station that moves within the band keeps /// one entry rather than leaving a trail. private static string Key(Spot spot) => $"{spot.Call.Text}|{spot.Band?.Name}"; }