Add the session, radio control, bandmap and cluster

Nonemm.Session holds what the operator is typing, what the log says about it and
what happens on Enter, with no UI toolkit behind it. Nonemm.Rig talks to
hamlib's rigctld and reconnects on its own. Nonemm.Spotting reads DX cluster
lines into a bandmap that drops spots after an hour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik
2026-08-27 10:39:53 +00:00
co-authored by Claude Opus 5
parent ef631f1dc4
commit ffeeb2cdc1
25 changed files with 1416 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
using Nonemm.Contests;
namespace Nonemm.Session;
/// What the operator has typed: the callsign, a value per exchange box, and
/// which box the cursor is in. The callsign box is field 0.
public sealed class EntryFields
{
private readonly List<string> values;
public EntryFields(IReadOnlyList<ExchangeField> exchange)
{
Exchange = exchange;
values = [.. Enumerable.Repeat("", exchange.Count)];
}
public IReadOnlyList<ExchangeField> Exchange { get; }
public string Call { get; set; } = "";
/// 0 is the callsign box; 1 and up are the exchange boxes in order.
public int Focus { get; private set; }
public int FieldCount => Exchange.Count + 1;
public string this[int field]
{
get => field == 0 ? Call : values[field - 1];
set
{
if (field == 0)
{
Call = value;
}
else
{
values[field - 1] = value;
}
}
}
public string ValueOf(ExchangeSlot slot)
{
for (int at = 0; at < Exchange.Count; at++)
{
if (Exchange[at].Slot == slot)
{
return values[at];
}
}
return "";
}
public void Set(ExchangeSlot slot, string value)
{
for (int at = 0; at < Exchange.Count; at++)
{
if (Exchange[at].Slot == slot)
{
values[at] = value;
}
}
}
public void FocusOn(int field) => Focus = Math.Clamp(field, 0, FieldCount - 1);
/// What space does: move to the next box, and round to the callsign again
/// from the last one.
public void Advance() => Focus = (Focus + 1) % FieldCount;
public void Retreat() => Focus = (Focus + FieldCount - 1) % FieldCount;
/// True when everything the contest asks for has been typed.
public bool IsComplete =>
Call.Trim().Length > 0 &&
Exchange.Where((f, at) => f.IsRequired && values[at].Trim().Length == 0).Any() == false;
public void Clear()
{
Call = "";
for (int at = 0; at < values.Count; at++)
{
values[at] = "";
}
Focus = 0;
}
}
+29
View File
@@ -0,0 +1,29 @@
using System.Globalization;
using Nonemm.Core;
namespace Nonemm.Session;
/// Reads a frequency typed into the callsign box, which is how an operator
/// changes band without touching the radio.
public static class FrequencyEntry
{
/// Kilohertz when the number is 1000 or more, megahertz when it is less, so
/// both `14025` and `14.025` reach 20 metres. Null when the text is not a
/// frequency or lands outside every band.
public static Frequency? Parse(string text)
{
string trimmed = text.Trim();
if (trimmed.Length == 0 || !trimmed.All(c => char.IsAsciiDigit(c) || c == '.'))
{
return null;
}
if (!double.TryParse(trimmed, NumberStyles.Float, CultureInfo.InvariantCulture, out double value))
{
return null;
}
Frequency frequency = value >= 1000
? Frequency.FromKilohertz(value)
: Frequency.FromMegahertz(value);
return Bands.ForFrequency(frequency) is null ? null : frequency;
}
}
+189
View File
@@ -0,0 +1,189 @@
using Nonemm.Contests;
using Nonemm.Core;
using Nonemm.Core.Country;
using Nonemm.Storage;
namespace Nonemm.Session;
/// The running contest: what the operator is typing, what the log says about
/// it, and what happens when the contact is logged. It holds no reference to a
/// UI toolkit, so the behaviour an operator judges the logger by is tested with
/// plain unit tests.
public sealed class LoggingSession
{
private readonly LogStore store;
private readonly CountryFile? countries;
public LoggingSession(
LogStore store,
Contest contest,
ContestInstance instance,
StationInfo me,
CountryFile? countries)
{
this.store = store;
this.countries = countries;
Contest = contest;
Instance = instance;
Me = me;
Log = new ContestLog(contest, me, countries);
Log.Restore(store.Qsos(instance.ContestNumber));
Entry = new EntryFields(contest.ExchangeFieldsFor(me));
SentNumber = NextSentNumber();
}
public Contest Contest { get; }
public ContestInstance Instance { get; }
public StationInfo Me { get; }
public ContestLog Log { get; }
public EntryFields Entry { get; }
/// With no radio connected this stays where it was last typed.
public Frequency Frequency { get; private set; } = Bands.Band20M.Low;
public Mode Mode { get; private set; } = Modes.Cw;
public int SentNumber { get; private set; }
public string Operator { get; set; } = "";
/// True while the operator is calling CQ rather than searching.
public bool IsRunning { get; set; }
public event EventHandler? Changed;
public event EventHandler<Qso>? Logged;
public void Tune(Frequency frequency, Mode? mode = null)
{
Frequency = frequency;
Mode = mode ?? Mode;
Changed?.Invoke(this, EventArgs.Empty);
}
/// What the log says about what is typed now. Null when there is no call to
/// judge yet.
public Verdict? Verdict()
{
string call = Entry.Call.Trim();
return call.Length == 0 ? null : Log.Judge(BuildQso(call));
}
public CountryLookup? Country() =>
Entry.Call.Trim().Length == 0 ? null : countries?.Find(Entry.Call.Trim());
/// Every earlier contact with the call being typed, newest first.
public IReadOnlyList<Qso> WorkedBefore() =>
Entry.Call.Trim().Length == 0 ? [] : Log.WorkedBefore(Entry.Call.Trim());
/// The frequency typed into the callsign box, if that is what it holds.
public Frequency? PendingQsy() => FrequencyEntry.Parse(Entry.Call);
/// Logs what is typed and clears the entry. Throws when the entry is not
/// complete, so a caller that has not checked cannot lose an exchange.
public Qso LogContact()
{
if (!Entry.IsComplete)
{
throw new InvalidOperationException($"the exchange for {Entry.Call} is not complete");
}
Qso scored = Log.Add(BuildQso(Entry.Call.Trim()));
Qso stored = store.Add(scored);
if (stored.TimestampUtc != scored.TimestampUtc)
{
Log.Replace(stored);
}
Entry.Clear();
SentNumber = NextSentNumber();
Logged?.Invoke(this, stored);
Changed?.Invoke(this, EventArgs.Empty);
return stored;
}
public void Wipe()
{
Entry.Clear();
Changed?.Invoke(this, EventArgs.Empty);
}
public void Delete(string id)
{
store.Delete(id);
Log.Remove(id);
Changed?.Invoke(this, EventArgs.Empty);
}
public void Update(Qso qso)
{
store.Update(qso);
Log.Replace(qso);
Changed?.Invoke(this, EventArgs.Empty);
}
private int NextSentNumber() =>
Log.Qsos.Count == 0 ? 1 : Log.Qsos.Max(q => q.SentNumber) + 1;
private Qso BuildQso(string call)
{
Callsign parsed = Callsign.Parse(call);
CountryLookup? country = countries?.Find(parsed);
Qso qso = new()
{
Id = Qso.NewId(),
TimestampUtc = DateTime.UtcNow.AddTicks(-(DateTime.UtcNow.Ticks % TimeSpan.TicksPerSecond)),
Call = parsed,
Frequency = Frequency,
Mode = Mode,
ContestName = Contest.Name,
ContestNumber = Instance.ContestNumber,
SentReport = DefaultReport(),
SentNumber = SentNumber,
CountryPrefix = country?.Entity.PrimaryPrefix ?? "",
Continent = country?.Continent ?? "",
StationPrefix = parsed.PortablePrefix ?? "",
WpxPrefix = parsed.WpxPrefix() ?? "",
Operator = Operator.Length > 0 ? Operator : Me.Callsign,
IsRunQso = IsRunning,
};
return ApplyExchange(qso);
}
private Qso ApplyExchange(Qso qso)
{
foreach (ExchangeField field in Entry.Exchange)
{
string value = Entry.ValueOf(field.Slot).Trim();
qso = field.Slot switch
{
ExchangeSlot.ReceivedReport => qso with { ReceivedReport = value },
ExchangeSlot.SerialNumber => qso with { ReceivedNumber = Number(value) },
ExchangeSlot.Zone => qso with { Zone = Number(value) },
ExchangeSlot.Section => qso with { Section = value.ToUpperInvariant() },
ExchangeSlot.Check => qso with { Check = Number(value) },
ExchangeSlot.Precedence => qso with { Precedence = value.ToUpperInvariant() },
ExchangeSlot.Exchange1 => qso with { Exchange1 = value.ToUpperInvariant() },
ExchangeSlot.MiscText => qso with { MiscText = value.ToUpperInvariant() },
ExchangeSlot.Name => qso with { Name = value },
ExchangeSlot.Qth => qso with { Qth = value },
ExchangeSlot.GridSquare => qso with { GridSquare = value.ToUpperInvariant() },
ExchangeSlot.Power => qso with { Power = value },
ExchangeSlot.Comment => qso with { Comment = value },
_ => qso,
};
}
return qso;
}
private string DefaultReport() => Mode.Category switch
{
ModeCategory.Cw => "599",
ModeCategory.Phone => "59",
_ => "599",
};
private static int Number(string value) => int.TryParse(value, out int parsed) ? parsed : 0;
}
+7
View File
@@ -1,5 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Nonemm.Core\Nonemm.Core.csproj" />
<ProjectReference Include="..\Nonemm.Contests\Nonemm.Contests.csproj" />
<ProjectReference Include="..\Nonemm.Storage\Nonemm.Storage.csproj" />
<ProjectReference Include="..\Nonemm.Formats\Nonemm.Formats.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>