Resynchronize a log with the other stations, and rescore it
A station that was away — a laptop that lost the network, one that was switched off — comes back missing whatever the others logged while it was gone. N1MM fills that in by walking the log an hour at a time, and this is that walk, on the same messages: | Message | What it says | |---|---| | `CHECKSUM` | here is what I hold for your contacts in this hour, and the contest and callsign I am logging | | `DELETEQS` | drop your copies of my contacts in that hour, I am sending them again | | `RESYNCQSO` | one of those contacts | | `CONFIRMED` | here is what the hour hashes to at my end — which also asks for the next hour | Each station is the authority on the contacts it made, so an hour that disagrees is replaced rather than merged. The answer to one block asks for the next, and the walk ends when the block starts after the end of the period. The Resynchronize tab counts the hours that agreed, the hours that were sent again and the hours that still disagreed, which are N1MM's columns. **`LogChecksum`** is N1MM's: a CRC32 over the `DXLOG` columns of the block, ordered by time and then callsign, leaving out what each station works out for itself — points, the three multiplier flags, the country prefix, the continent, the WPX prefix and `IsOriginal`. Two stations may disagree about those and still hold the same log. A checksum that disagrees for any other reason costs one block of traffic, which is what a resync is for. **The link still holds no log.** It asks one through `ContactLog` for the contacts of a computer in a block of time, and `SessionContactLog` answers on the window thread, because that is where the log is touched. **Every contact logged here now carries the computer that made it.** N1MM stamps `NetBiosName` and `NetworkedCompNr` on every row and this program did not, so its own contacts belonged to no station and a resync could not ask for them. **Rescore** writes the points and multiplier flags of a period back to the database. The log in memory scores itself whenever it changes; the stored rows do not, and N1MM reads them rather than working them out. A contact that arrives from another station after the one that took a multiplier leaves those columns behind. A station can also ask the others to rescore when its resync finishes, which is the flag on the end of `CHECKSUM`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Network;
|
||||
|
||||
/// What the link asks the log while a resync is running. The link itself keeps
|
||||
/// no contacts: it frames the messages and counts the blocks, and the program
|
||||
/// that owns the log answers these.
|
||||
///
|
||||
/// The socket thread calls them, so an implementation whose log is owned
|
||||
/// somewhere else — a window thread, say — has to hand the question over and
|
||||
/// wait for the answer.
|
||||
public interface ContactLog
|
||||
{
|
||||
/// The contest being logged here. A station resynchronising into another
|
||||
/// contest is told rather than answered.
|
||||
string ContestName { get; }
|
||||
|
||||
/// The callsign this station is logging under, for the same reason.
|
||||
string MyCall { get; }
|
||||
|
||||
/// The contacts one computer made inside one block of time, oldest first.
|
||||
IReadOnlyList<Qso> Contacts(string computerName, DateTime fromUtc, TimeSpan length);
|
||||
}
|
||||
@@ -21,3 +21,9 @@ public sealed record ContactDeleted(
|
||||
int ContestNumber,
|
||||
string StationName)
|
||||
: ContactUpdate(StationName);
|
||||
|
||||
/// Drop every contact this station holds for `StationName` between
|
||||
/// `FromUtc` and `FromUtc + Length`. It arrives in front of the same block
|
||||
/// sent again, so a resync replaces a block rather than adding to it.
|
||||
public sealed record ContactsCleared(string StationName, DateTime FromUtc, TimeSpan Length)
|
||||
: ContactUpdate(StationName);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace Nonemm.Network;
|
||||
|
||||
/// The ordinary CRC32 — the one with polynomial 0xEDB88320, all bits set to
|
||||
/// start and inverted at the end — over one byte per character. N1MM hashes its
|
||||
/// log blocks with it and keeps the result as a signed integer, which is what
|
||||
/// goes on the wire, so this returns one too.
|
||||
///
|
||||
/// A character above 255 cannot appear in a log column N1MM wrote, and is
|
||||
/// hashed by its low byte rather than by throwing.
|
||||
internal static class Crc32
|
||||
{
|
||||
private const uint Polynomial = 0xEDB88320;
|
||||
|
||||
private static readonly uint[] Table = BuildTable();
|
||||
|
||||
internal static int Of(string text)
|
||||
{
|
||||
uint crc = 0xFFFFFFFF;
|
||||
foreach (char c in text)
|
||||
{
|
||||
crc = (crc >> 8) ^ Table[(crc ^ (byte)c) & 0xFF];
|
||||
}
|
||||
return unchecked((int)(crc ^ 0xFFFFFFFF));
|
||||
}
|
||||
|
||||
private static uint[] BuildTable()
|
||||
{
|
||||
uint[] table = new uint[256];
|
||||
for (uint i = 0; i < table.Length; i++)
|
||||
{
|
||||
uint value = i;
|
||||
for (int bit = 0; bit < 8; bit++)
|
||||
{
|
||||
value = (value & 1) != 0 ? (value >> 1) ^ Polynomial : value >> 1;
|
||||
}
|
||||
table[i] = value;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Network;
|
||||
|
||||
/// The checksum of one block of contacts. Two stations compare these to find
|
||||
/// out whether they hold the same log without sending the log.
|
||||
///
|
||||
/// It is N1MM's: a CRC32 over the `DXLOG` columns of every contact in the
|
||||
/// block, in the order the columns are read out of the database, oldest first
|
||||
/// and by callsign within a timestamp. The columns this program works out from
|
||||
/// the rules — points, the three multiplier flags, the country prefix, the
|
||||
/// continent, the WPX prefix and `IsOriginal` — are left out, because the two
|
||||
/// stations are allowed to disagree about them. `ContestNR` counts only when it
|
||||
/// is -1.
|
||||
///
|
||||
/// The checksum is over what a station stored, so a field that one program
|
||||
/// writes as empty and the other as a space is a difference. N1MM writes a
|
||||
/// single space into `GridSquare`, `MiscText`, `ContactType` and
|
||||
/// `RoverLocation` when it has nothing for them, so an empty value in those
|
||||
/// four is hashed as a space.
|
||||
///
|
||||
/// A checksum that disagrees is not a fault: the block is sent again, which is
|
||||
/// what a resync is for. It only costs traffic.
|
||||
public static class LogChecksum
|
||||
{
|
||||
public static int Of(IEnumerable<Qso> contacts)
|
||||
{
|
||||
StringBuilder text = new();
|
||||
IEnumerable<Qso> ordered = contacts
|
||||
.OrderBy(q => q.TimestampUtc)
|
||||
.ThenBy(q => q.Call.Text, StringComparer.Ordinal);
|
||||
foreach (Qso qso in ordered)
|
||||
{
|
||||
Append(text, qso);
|
||||
}
|
||||
return Crc32.Of(text.ToString());
|
||||
}
|
||||
|
||||
/// The contacts of one station inside one block of time. Both ends of a
|
||||
/// resync pick the same rows this way.
|
||||
public static IReadOnlyList<Qso> Block(
|
||||
IEnumerable<Qso> contacts,
|
||||
string computerName,
|
||||
DateTime fromUtc,
|
||||
TimeSpan length) =>
|
||||
contacts
|
||||
.Where(q =>
|
||||
string.Equals(q.StationName, computerName, StringComparison.OrdinalIgnoreCase)
|
||||
&& q.TimestampUtc >= fromUtc
|
||||
&& q.TimestampUtc < fromUtc + length)
|
||||
.OrderBy(q => q.TimestampUtc)
|
||||
.ThenBy(q => q.Call.Text, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
private static void Append(StringBuilder text, Qso qso)
|
||||
{
|
||||
text.Append(qso.TimestampUtc.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
|
||||
text.Append(qso.Call.Text);
|
||||
Number(text, qso.Frequency.Kilohertz);
|
||||
Number(text, qso.QsxFrequency.Kilohertz);
|
||||
text.Append(qso.Mode.Name);
|
||||
text.Append(qso.ContestName);
|
||||
text.Append(qso.SentReport);
|
||||
text.Append(qso.ReceivedReport);
|
||||
text.Append(qso.StationPrefix);
|
||||
text.Append(qso.Qth);
|
||||
text.Append(qso.Name);
|
||||
text.Append(qso.Comment);
|
||||
Number(text, qso.ReceivedNumber);
|
||||
text.Append(qso.Section);
|
||||
text.Append(qso.Precedence);
|
||||
Number(text, qso.Check);
|
||||
Number(text, qso.Zone);
|
||||
Number(text, qso.SentNumber);
|
||||
text.Append(qso.Power);
|
||||
Number(text, qso.Band?.MegahertzLabel ?? 0);
|
||||
text.Append(qso.Exchange1);
|
||||
Number(text, qso.RadioNumber);
|
||||
text.Append(qso.Operator);
|
||||
text.Append(Filled(qso.GridSquare));
|
||||
// the contest number counts only when it says the contact belongs to no
|
||||
// contest
|
||||
if (qso.ContestNumber == -1)
|
||||
{
|
||||
text.Append("-1");
|
||||
}
|
||||
text.Append(Filled(qso.MiscText));
|
||||
text.Append(Filled(qso.ContactType));
|
||||
Number(text, qso.RunPosition);
|
||||
text.Append(Filled(qso.RoverLocation));
|
||||
Flag(text, qso.IsRadioInterfaced);
|
||||
Number(text, qso.NetworkedComputerNumber);
|
||||
text.Append(qso.StationName);
|
||||
Flag(text, qso.IsRunQso);
|
||||
text.Append(qso.Id);
|
||||
Flag(text, qso.IsClaimed);
|
||||
}
|
||||
|
||||
/// A column N1MM stores as a single space when it has nothing for it.
|
||||
private static string Filled(string value) => value.Length == 0 ? " " : value;
|
||||
|
||||
private static void Number(StringBuilder text, double value) =>
|
||||
text.Append(value.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
private static void Number(StringBuilder text, int value) =>
|
||||
text.Append(value.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
private static void Flag(StringBuilder text, bool value) => text.Append(value ? '1' : '0');
|
||||
}
|
||||
@@ -102,6 +102,21 @@ public sealed class NetworkedStation
|
||||
/// window.
|
||||
public bool IsMine { get; internal init; }
|
||||
|
||||
/// What a resync with this station is doing, and how it went. N1MM counts
|
||||
/// the blocks an hour at a time, which is why its columns say hours: `Ok`
|
||||
/// is a block both ends already agreed on, `Fixed` is one this station was
|
||||
/// told to drop and was sent again, `Failed` is one that still did not
|
||||
/// agree after being sent.
|
||||
public bool IsResyncing { get; internal set; }
|
||||
|
||||
public string ResyncStatus { get; internal set; } = "";
|
||||
|
||||
public int HoursOk { get; internal set; }
|
||||
|
||||
public int HoursFixed { get; internal set; }
|
||||
|
||||
public int HoursFailed { get; internal set; }
|
||||
|
||||
/// Set when the operator closed the connection by hand. The beacons keep
|
||||
/// arriving and the station stays in the list, but nothing opens a
|
||||
/// connection to it again until the operator asks for one.
|
||||
|
||||
@@ -88,6 +88,10 @@ public sealed class StationLink : IDisposable
|
||||
set => mine.Operator = value;
|
||||
}
|
||||
|
||||
/// The log a resync reads. Without one this station answers no checksum:
|
||||
/// it has nothing to compare and nothing to send.
|
||||
public ContactLog? Log { get; set; }
|
||||
|
||||
/// This computer's own row, which is in the station list as well. The
|
||||
/// window reads its pass frequency and its counts off it.
|
||||
public NetworkedStation Me => mine;
|
||||
@@ -108,6 +112,10 @@ public sealed class StationLink : IDisposable
|
||||
|
||||
public event EventHandler<string>? Failed;
|
||||
|
||||
/// Another station asked for a rescore when its resync is done. The log is
|
||||
/// not this program's to score, so the owner is told and does it.
|
||||
public event EventHandler? RescoreRequested;
|
||||
|
||||
/// Every message that arrives, whatever it says. Nothing needs this to run:
|
||||
/// it is what the messages pane of the network status window prints when
|
||||
/// the operator turns tracing on.
|
||||
@@ -322,6 +330,50 @@ public sealed class StationLink : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a block of the log a resync compares at a time. N1MM uses an
|
||||
/// hour, which is why its counts are of hours.
|
||||
public static readonly TimeSpan ResyncBlock = TimeSpan.FromHours(1);
|
||||
|
||||
/// Starts a resync with one station: are your contacts and my copies of
|
||||
/// them the same, hour by hour, between these two times?
|
||||
///
|
||||
/// It is a walk rather than one message. This station sends the checksum of
|
||||
/// the first block, the other end answers with its own and sends the block
|
||||
/// again if they differ, and its answer is what asks for the next block. The
|
||||
/// walk stops when the block being asked about starts after `toUtc`.
|
||||
public void StartResync(
|
||||
NetworkedStation station,
|
||||
DateTime fromUtc,
|
||||
DateTime toUtc,
|
||||
bool everyStation = false,
|
||||
bool rescoreWhenDone = false)
|
||||
{
|
||||
station.IsResyncing = true;
|
||||
station.ResyncStatus = "Starting";
|
||||
station.HoursOk = 0;
|
||||
station.HoursFixed = 0;
|
||||
station.HoursFailed = 0;
|
||||
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||
_ = AskAboutBlockAsync(station, fromUtc, toUtc, everyStation, rescoreWhenDone);
|
||||
}
|
||||
|
||||
/// The same walk with every station that is connected, which is N1MM's
|
||||
/// Resync All.
|
||||
public void StartResyncAll(
|
||||
DateTime fromUtc,
|
||||
DateTime toUtc,
|
||||
bool everyStation = false,
|
||||
bool rescoreWhenDone = false)
|
||||
{
|
||||
foreach (NetworkedStation station in stations.Values)
|
||||
{
|
||||
if (!station.IsMine && station.IsConnected)
|
||||
{
|
||||
StartResync(station, fromUtc, toUtc, everyStation, rescoreWhenDone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks every station whether it is there. The answer sets `EchoTime`.
|
||||
public Task<int> SendEchoRequestAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
@@ -571,6 +623,20 @@ public sealed class StationLink : IDisposable
|
||||
case "ECHO":
|
||||
station.EchoTime = DateTime.UtcNow - echoSentAt;
|
||||
break;
|
||||
case "CHECKSUM":
|
||||
_ = AnswerChecksumAsync(station, record);
|
||||
break;
|
||||
case "CONFIRMED":
|
||||
_ = TakeConfirmationAsync(station, record);
|
||||
break;
|
||||
case "DELETEQS":
|
||||
station.HoursFixed++;
|
||||
station.ResyncStatus = $"Replacing {record.Time(0):yyyy-MM-dd HH:mm}Z";
|
||||
if (StationMessages.Read(record) is { } cleared)
|
||||
{
|
||||
UpdateArrived?.Invoke(this, cleared);
|
||||
}
|
||||
break;
|
||||
case "TALK":
|
||||
TalkArrived?.Invoke(this, record.Field(0));
|
||||
break;
|
||||
@@ -584,6 +650,137 @@ public sealed class StationLink : IDisposable
|
||||
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// One block of the walk: what this station holds for the other one, sent
|
||||
/// as a checksum for it to compare with its own.
|
||||
private async Task AskAboutBlockAsync(
|
||||
NetworkedStation station,
|
||||
DateTime fromUtc,
|
||||
DateTime toUtc,
|
||||
bool everyStation,
|
||||
bool rescoreWhenDone)
|
||||
{
|
||||
if (Log is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
int checksum = LogChecksum.Of(Log.Contacts(station.ComputerName, fromUtc, ResyncBlock));
|
||||
await SendToAsync(
|
||||
station,
|
||||
StationMessages.CheckSum(
|
||||
fromUtc,
|
||||
toUtc,
|
||||
checksum,
|
||||
Log.ContestName,
|
||||
Log.MyCall,
|
||||
(int)ResyncBlock.TotalMinutes,
|
||||
everyStation,
|
||||
rescoreWhenDone,
|
||||
StationNumber,
|
||||
computerName)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// A station asked whether we hold the same block. This end is the
|
||||
/// authority on the contacts it made: when the two checksums differ it
|
||||
/// tells the asker to drop its copies and sends the block again, and it
|
||||
/// answers with its own checksum either way.
|
||||
private async Task AnswerChecksumAsync(NetworkedStation station, StationRecord record)
|
||||
{
|
||||
if (Log is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
DateTime fromUtc = record.Time(0);
|
||||
DateTime toUtc = record.Time(1);
|
||||
int theirs = record.Number(2);
|
||||
string contest = record.Field(3);
|
||||
string call = record.Field(4);
|
||||
TimeSpan block = TimeSpan.FromMinutes(Math.Max(1, record.Number(5)));
|
||||
bool everyStation = record.Flag(6);
|
||||
if (record.Flag(7))
|
||||
{
|
||||
RescoreRequested?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
if (!string.Equals(contest, Log.ContestName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Failed?.Invoke(
|
||||
this,
|
||||
$"{station.ComputerName} is logging {contest}, this station is logging {Log.ContestName} — not resynchronising");
|
||||
return;
|
||||
}
|
||||
if (call.Length > 0 && !string.Equals(call, Log.MyCall, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Failed?.Invoke(this, $"{station.ComputerName} is logging as {call}, this station as {Log.MyCall}");
|
||||
}
|
||||
IReadOnlyList<Qso> mine = Log.Contacts(computerName, fromUtc, block);
|
||||
int ours = LogChecksum.Of(mine);
|
||||
if (everyStation)
|
||||
{
|
||||
StartResyncAll(fromUtc, toUtc);
|
||||
}
|
||||
if (theirs != ours)
|
||||
{
|
||||
await SendToAsync(
|
||||
station,
|
||||
StationMessages.DeleteContacts(
|
||||
fromUtc,
|
||||
computerName,
|
||||
(int)block.TotalMinutes,
|
||||
StationNumber,
|
||||
computerName)).ConfigureAwait(false);
|
||||
foreach (Qso qso in mine)
|
||||
{
|
||||
await SendToAsync(station, StationMessages.Resynced(qso, StationNumber, computerName))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
await SendToAsync(
|
||||
station,
|
||||
StationMessages.Confirmed(
|
||||
fromUtc,
|
||||
toUtc,
|
||||
ours,
|
||||
(int)block.TotalMinutes,
|
||||
StationNumber,
|
||||
computerName)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// The answer to one block. It says how that block went and asks about the
|
||||
/// next one, which is what walks the resync along; a block that starts
|
||||
/// after the end of the period is the end of the walk.
|
||||
private async Task TakeConfirmationAsync(NetworkedStation station, StationRecord record)
|
||||
{
|
||||
if (Log is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
DateTime fromUtc = record.Time(0);
|
||||
DateTime toUtc = record.Time(1);
|
||||
int theirs = record.Number(2);
|
||||
TimeSpan block = TimeSpan.FromMinutes(Math.Max(1, record.Number(3)));
|
||||
if (fromUtc > toUtc)
|
||||
{
|
||||
station.IsResyncing = false;
|
||||
station.ResyncStatus = "Resync complete";
|
||||
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||
return;
|
||||
}
|
||||
// the contacts arrived before this answer did, so what is counted here
|
||||
// is the block as it now stands
|
||||
if (LogChecksum.Of(Log.Contacts(station.ComputerName, fromUtc, block)) == theirs)
|
||||
{
|
||||
station.HoursOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
station.HoursFailed++;
|
||||
}
|
||||
DateTime next = fromUtc + block;
|
||||
station.ResyncStatus = $"Syncing {next:yyyy-MM-dd HH:mm}Z";
|
||||
StationsChanged?.Invoke(this, EventArgs.Empty);
|
||||
await AskAboutBlockAsync(station, next, toUtc, everyStation: false, rescoreWhenDone: false)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// Closes a connection that has failed or gone quiet. The station stays in
|
||||
/// the list: it
|
||||
/// is still part of the entry, and the window showing it as not connected
|
||||
|
||||
@@ -23,6 +23,9 @@ namespace Nonemm.Network;
|
||||
/// | `XMIT` | a station started or stopped transmitting | yes | yes |
|
||||
/// | `BANDMAP` | which band and mode a station is on | yes | yes |
|
||||
/// | `STATUS` | everything the network status window shows about a station | yes | yes |
|
||||
/// | `CHECKSUM` | the checksum of one block of the log, which starts a resync | yes | yes |
|
||||
/// | `CONFIRMED` | the answer to a checksum, and what moves a resync on | yes | yes |
|
||||
/// | `DELETEQS` | drop your copies of one station's contacts in this block | yes | yes |
|
||||
///
|
||||
/// The rest — the score and sked windows, the log check, the spot lists, the
|
||||
/// serial-number pool — are passed over. An unknown type is not an error: N1MM
|
||||
@@ -190,10 +193,85 @@ public static class StationMessages
|
||||
record.Field(8),
|
||||
record.Flag(16));
|
||||
|
||||
/// The checksum of one block of the log, which is how a resync asks
|
||||
/// "do you and I hold the same contacts for this station between these two
|
||||
/// times?".
|
||||
///
|
||||
/// The sender hashes its copy of the *other* station's contacts and the
|
||||
/// receiver hashes its own, so each station is the authority on the
|
||||
/// contacts it made. The contest name and the callsign are carried so a
|
||||
/// station that is logging another contest, or another callsign, is told
|
||||
/// rather than resynchronised into the wrong log.
|
||||
public static StationRecord CheckSum(
|
||||
DateTime fromUtc,
|
||||
DateTime toUtc,
|
||||
int checksum,
|
||||
string contestName,
|
||||
string myCall,
|
||||
int minutes,
|
||||
bool everyStation,
|
||||
bool rescoreWhenDone,
|
||||
int stationNumber,
|
||||
string computerName) =>
|
||||
Record(
|
||||
"CHECKSUM",
|
||||
stationNumber,
|
||||
computerName,
|
||||
[
|
||||
StationRecord.Written(fromUtc),
|
||||
StationRecord.Written(toUtc),
|
||||
Text(checksum),
|
||||
contestName,
|
||||
myCall,
|
||||
Text(minutes),
|
||||
StationRecord.Written(everyStation),
|
||||
StationRecord.Written(rescoreWhenDone),
|
||||
]);
|
||||
|
||||
/// The answer to a checksum: what the block hashes to at this end. The
|
||||
/// asker compares it with its own and moves on to the next block, which is
|
||||
/// what walks a resync through the contest.
|
||||
public static StationRecord Confirmed(
|
||||
DateTime fromUtc,
|
||||
DateTime toUtc,
|
||||
int checksum,
|
||||
int minutes,
|
||||
int stationNumber,
|
||||
string computerName) =>
|
||||
Record(
|
||||
"CONFIRMED",
|
||||
stationNumber,
|
||||
computerName,
|
||||
[
|
||||
StationRecord.Written(fromUtc),
|
||||
StationRecord.Written(toUtc),
|
||||
Text(checksum),
|
||||
Text(minutes),
|
||||
]);
|
||||
|
||||
/// Drop your copies of the named station's contacts in this block. It goes
|
||||
/// out in front of the contacts themselves, so the block is replaced rather
|
||||
/// than added to.
|
||||
public static StationRecord DeleteContacts(
|
||||
DateTime fromUtc,
|
||||
string ofComputerName,
|
||||
int minutes,
|
||||
int stationNumber,
|
||||
string computerName) =>
|
||||
Record(
|
||||
"DELETEQS",
|
||||
stationNumber,
|
||||
computerName,
|
||||
[StationRecord.Written(fromUtc), ofComputerName, Text(minutes)]);
|
||||
|
||||
/// What a message that has arrived means, or null for one this program does
|
||||
/// nothing with.
|
||||
public static ContactUpdate? Read(StationRecord record) => record.Type switch
|
||||
{
|
||||
"DELETEQS" => new ContactsCleared(
|
||||
record.Field(1),
|
||||
record.Time(0),
|
||||
TimeSpan.FromMinutes(record.Number(2))),
|
||||
"QSO" or "RESYNCQSO" => new ContactLogged(
|
||||
QsoRecord.Read(record, 1, record.ComputerName),
|
||||
record.ComputerName),
|
||||
|
||||
Reference in New Issue
Block a user