Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/design/messaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ no acks, repeated on a `BulletinSchedule` (#272): doubling waits from 1 minute t
## Out of scope

- The email gateway and auto-reply rules (A11-AA): AprsMap builds them on `Send` and `MessageReceived`. ANSRVR's
message forms are `AnsrvrCommand` (#272). An
message forms are `AnsrvrCommand` (#272), and its relays and notices are `AnsrvrMessage` (#281). An
auto-reply has no message ID and no retries, which `Send` allows with `RetryProfile.None` and `withId: false`.
- ITEM-in-MSG and the 1.2 message servers (A12-MSG-01, 02).
- Gating messages between APRS-IS and RF: that is the two-way IGate's job, `InternetToRfGate` in `AprsNet.IGate`
Expand Down
129 changes: 129 additions & 0 deletions src/AprsNet.Messaging/AnsrvrMessage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using System.Diagnostics.CodeAnalysis;
using AprsNet.Packets;

namespace AprsNet.Messaging;

/// <summary>What kind of group traffic an <see cref="AnsrvrMessage"/> is.</summary>
public enum AnsrvrMessageKind
{
/// <summary>A member's post, relayed to this station by ANSRVR in the member's name.</summary>
Relay,

/// <summary>ANSRVR's own reply about a group, such as <c>N:NZEVENT 2 Messages Sent</c>.</summary>
Notice,
}

/// <summary>
/// A message from a group on ANSRVR, the APRS announcement server: a member's post relayed to this station, or
/// ANSRVR's notice about a group. Both read <c>N:GROUP text</c>; <see cref="TryParse(IncomingMessage, out AnsrvrMessage?)"/>
/// tells them apart by who sent them.
/// </summary>
/// <remarks>
/// <para>
/// A relay comes from the member who posted, not from ANSRVR: ANSRVR's host sends it in their name (on APRS-IS with
/// the destination <c>APWW11</c> and <c>qAO</c>), with the poster's <c>CQ GROUP</c> replaced by <c>N:GROUP</c> and no
/// message ID, so it isn't acked. For example, when N1KXJ-14 sends <c>CQ NEDR Pretty quiet</c>, each other member gets
/// <c>N1KXJ-14&gt;APWW11,qAO,KJ4ERJ-15::N1KXJ-10 :N:NEDR Pretty quiet</c>.
/// </para>
/// <para>
/// A notice comes from <see cref="AnsrvrCommand.Ansrvr"/> with a message ID, and is acked like any message: the answer
/// to a join (<c>N:C4FM Now Monitoring with 2 Members</c>), a post (<c>N:NEDR 2 Messages Sent</c>) or a leave
/// (<c>N:HOTG Removed From Group</c>). ANSRVR's replies that name no group, such as <c>No Memberships</c>, aren't
/// recognised.
/// </para>
/// <para>
/// A message with a message ID from any other station is never a relay, even if it starts with <c>N:</c>: a person
/// typed it.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// station.MessageReceived += (_, e) =>
/// {
/// if (AnsrvrMessage.TryParse(e.Message, out var group))
/// {
/// ShowInGroup(group.Group, group.From, group.Text);
/// }
/// };
/// </code>
/// </example>
public sealed class AnsrvrMessage
{
private const string Prefix = "N:";

private AnsrvrMessage(AnsrvrMessageKind kind, string group, Callsign from, string text)
{
Kind = kind;
Group = group;
From = from;
Text = text;
}

/// <summary>A member's post, or ANSRVR's notice.</summary>
public AnsrvrMessageKind Kind { get; }

/// <summary>The group, as written, such as <c>NEDR</c>.</summary>
public string Group { get; }

/// <summary>
/// For a <see cref="AnsrvrMessageKind.Relay"/>, the member who posted; for a notice, <see cref="AnsrvrCommand.Ansrvr"/>.
/// </summary>
public Callsign From { get; }

/// <summary>The text after the group, such as <c>Pretty quiet</c> or <c>2 Messages Sent</c>; empty when there is none.</summary>
public string Text { get; }

/// <summary>Recognises a message <see cref="MessagingStation.MessageReceived"/> gave.</summary>
/// <returns><see langword="false"/> if <paramref name="message"/> isn't a relay or notice from a group.</returns>
public static bool TryParse(IncomingMessage message, [NotNullWhen(true)] out AnsrvrMessage? ansrvr)
{
ArgumentNullException.ThrowIfNull(message);
return TryParse(message.From, message.Text, message.MessageId, out ansrvr);
}

/// <summary>Recognises a message from <paramref name="from"/> with <paramref name="text"/> and its message ID.</summary>
/// <param name="from">The station it came from.</param>
/// <param name="text">The message text, without the message ID.</param>
/// <param name="messageId">Its message ID, or <see langword="null"/> when it has none.</param>
/// <param name="ansrvr">The relay or notice.</param>
/// <returns><see langword="false"/> if it isn't a relay or notice from a group.</returns>
public static bool TryParse(
Callsign from,
string? text,
string? messageId,
[NotNullWhen(true)] out AnsrvrMessage? ansrvr
)
{
ArgumentNullException.ThrowIfNull(from);
ansrvr = null;
if (text is null || !text.StartsWith(Prefix, StringComparison.Ordinal))
{
return false;
}

var rest = text[Prefix.Length..];
var space = rest.IndexOf(' ', StringComparison.Ordinal);
var group = space < 0 ? rest : rest[..space];
if (group.Length == 0 || group.Any(char.IsControl))
{
return false;
}

var fromAnsrvr = string.Equals(from.Value, AnsrvrCommand.Ansrvr.Value, StringComparison.OrdinalIgnoreCase);
if (!fromAnsrvr && messageId is not null)
{
return false;
}

ansrvr = new AnsrvrMessage(
fromAnsrvr ? AnsrvrMessageKind.Notice : AnsrvrMessageKind.Relay,
group,
from,
space < 0 ? string.Empty : rest[(space + 1)..]
);
return true;
}

/// <inheritdoc />
public override string ToString() => $"{From.Value} {Prefix}{Group} {Text}".TrimEnd();
}
11 changes: 11 additions & 0 deletions src/AprsNet.Messaging/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -245,3 +245,14 @@ AprsNet.Messaging.QueryState.Status.get -> AprsNet.Packets.StatusReport?
AprsNet.Messaging.QueryState.Status.init -> void
AprsNet.Messaging.QueryState.Weather.get -> AprsNet.Packets.AprsPayload?
AprsNet.Messaging.QueryState.Weather.init -> void
AprsNet.Messaging.AnsrvrMessage
AprsNet.Messaging.AnsrvrMessage.From.get -> AprsNet.Packets.Callsign!
AprsNet.Messaging.AnsrvrMessage.Group.get -> string!
AprsNet.Messaging.AnsrvrMessage.Kind.get -> AprsNet.Messaging.AnsrvrMessageKind
AprsNet.Messaging.AnsrvrMessage.Text.get -> string!
AprsNet.Messaging.AnsrvrMessageKind
AprsNet.Messaging.AnsrvrMessageKind.Notice = 1 -> AprsNet.Messaging.AnsrvrMessageKind
AprsNet.Messaging.AnsrvrMessageKind.Relay = 0 -> AprsNet.Messaging.AnsrvrMessageKind
override AprsNet.Messaging.AnsrvrMessage.ToString() -> string!
static AprsNet.Messaging.AnsrvrMessage.TryParse(AprsNet.Messaging.IncomingMessage! message, out AprsNet.Messaging.AnsrvrMessage? ansrvr) -> bool
static AprsNet.Messaging.AnsrvrMessage.TryParse(AprsNet.Packets.Callsign! from, string? text, string? messageId, out AprsNet.Messaging.AnsrvrMessage? ansrvr) -> bool
14 changes: 14 additions & 0 deletions src/AprsNet.Messaging/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,20 @@ drops a member after 12 hours without one.
station.Send(AnsrvrCommand.Ansrvr, AnsrvrCommand.Join("NZEVENT").Text);
```

`AnsrvrMessage` recognises what comes back from a group. Both forms read `N:GROUP text`:
- **A relay** is another member's post. It arrives from that member, not from ANSRVR, and has no message ID.
- **A notice** is ANSRVR's own reply, such as `N:NZEVENT 2 Messages Sent`.

```csharp
station.MessageReceived += (_, e) =>
{
if (AnsrvrMessage.TryParse(e.Message, out var group) && group.Kind == AnsrvrMessageKind.Relay)
{
ShowInGroup(group.Group, group.From, group.Text);
}
};
```

`QueryResponder` answers the queries of APRS101 ch. 15 from what the app gives it in a `QueryState`: its position and
status, weather, IGate capabilities, objects, the stations it has heard and the messages waiting. It does no I/O; it
returns the payloads to send and how long to wait first. Directed queries (`?APRSP`, `?APRSD`, `?PING?` …) are answered
Expand Down
135 changes: 135 additions & 0 deletions tests/AprsNet.Messaging.Tests/AnsrvrMessageTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
using AprsNet.Packets;

namespace AprsNet.Messaging.Tests;

/// <summary>
/// ANSRVR's group traffic as captured on APRS-IS on 2026-10-18 and 2026-10-27 (#272, #281): relays come from the
/// member who posted with no message ID, notices from ANSRVR with one.
/// </summary>
public sealed class AnsrvrMessageTests
{
private static IncomingMessage Received(string line)
{
var packet = AprsPacket.Parse(line);
if (packet.Payload is ThirdPartyPayload { Packet: { } inner })
{
packet = inner;
}

var message = Assert.IsType<MessagePayload>(packet.Payload);
return new IncomingMessage(
packet.Source!,
message.Text,
message.MessageId,
DateTimeOffset.UnixEpoch,
"aprs-is",
packet
);
}

[Theory]
[InlineData(
"N1KXJ-14>APWW11,qAO,KJ4ERJ-15::N1KXJ-10 :N:NEDR Pretty quiet ot here...",
"N1KXJ-14",
"NEDR",
"Pretty quiet ot here..."
)]
[InlineData(
"9M2PJU>APWW11,qAO,KJ4ERJ-15::K7XYG-7 :N:HOTG PSE JOIN #APRSMY Net https://aprsmy.hamradio.my",
"9M2PJU",
"HOTG",
"PSE JOIN #APRSMY Net https://aprsmy.hamradio.my"
)]
// Gated to RF by a two-way IGate: the same text, another path.
[InlineData(
"N0CALL-10>APZNET,WIDE1-1:}N1KXJ-14>APWW11,TCPIP,N0CALL-10*::N0CALL-9 :N:NEDR Hi",
"N1KXJ-14",
"NEDR",
"Hi"
)]
public void ARelayIsAMembersPost(string line, string from, string group, string text)
{
var received = Received(line);

Assert.True(AnsrvrMessage.TryParse(received, out var relay));

Assert.Equal(AnsrvrMessageKind.Relay, relay.Kind);
Assert.Equal(from, relay.From.Value);
Assert.Equal(group, relay.Group);
Assert.Equal(text, relay.Text);
}

[Theory]
[InlineData(
"ANSRVR>APWW11,KJ4ERJ-15*,TCPIP*,qAS,KJ4ERJ-15::N1KXJ-14 :N:NEDR 2 Messages Sent{HV}",
"NEDR",
"2 Messages Sent"
)]
[InlineData(
"ANSRVR>APWW11,KJ4ERJ-15*,TCPIP*,qAS,KJ4ERJ-15::NZ0P-3 :N:C4FM Now Monitoring with 2 Members{AB}",
"C4FM",
"Now Monitoring with 2 Members"
)]
[InlineData(
"ANSRVR>APWW11,KJ4ERJ-15*,TCPIP*,qAS,KJ4ERJ-15::NZ0P-3 :N:APRSNETTEST2 Send D APRSNETTEST2 <Description>{BI}",
"APRSNETTEST2",
"Send D APRSNETTEST2 <Description>"
)]
[InlineData(
"ANSRVR>APWW11,KJ4ERJ-15*,TCPIP*,qAS,KJ4ERJ-15::NZ0P-3 :N:HOTG Removed From Group, Left OwnerLess!{BK}",
"HOTG",
"Removed From Group, Left OwnerLess!"
)]
public void ANoticeIsAnsrvrsOwn(string line, string group, string text)
{
Assert.True(AnsrvrMessage.TryParse(Received(line), out var notice));

Assert.Equal(AnsrvrMessageKind.Notice, notice.Kind);
Assert.Equal(AnsrvrCommand.Ansrvr, notice.From);
Assert.Equal(group, notice.Group);
Assert.Equal(text, notice.Text);
}

[Theory]
// A person's message that starts with N: has a message ID.
[InlineData("W1AW>APRS,TCPIP*,qAC,T2TEST::N0CALL-9 :N:NEDR not a relay{01")]
// ANSRVR's replies with no group.
[InlineData("ANSRVR>APWW11,KJ4ERJ-15*,TCPIP*,qAS,KJ4ERJ-15::NZ0P-3 :No Memberships{BL}")]
[InlineData("ANSRVR>APWW11,KJ4ERJ-15*,TCPIP*,qAS,KJ4ERJ-15::NZ0P-3 :Duplicate (retry?) request suppressed")]
[InlineData("ANSRVR>APWW11,KJ4ERJ-15*,TCPIP*,qAS,KJ4ERJ-15::NZ0P-3 :N: no group{BM}")]
// A post to ANSRVR, and ordinary messages.
[InlineData("N1KXJ-14>APSTKA,TCPIP*,qAS,N1KXJ-W3::ANSRVR :CQ NEDR Pretty quiet{9W")]
[InlineData("W1AW>APRS,TCPIP*,qAC,T2TEST::N0CALL-9 :Net at 7pm")]
[InlineData("W1AW>APRS,TCPIP*,qAC,T2TEST::N0CALL-9 :n:NEDR lower case")]
public void OtherMessagesAreNotGroupTraffic(string line)
{
Assert.False(AnsrvrMessage.TryParse(Received(line), out var ansrvr));
Assert.Null(ansrvr);
}

[Fact]
public void AGroupWithNoTextHasEmptyText()
{
Assert.True(AnsrvrMessage.TryParse(Callsign.Parse("N1KXJ-14"), "N:NEDR", null, out var relay));

Assert.Equal("NEDR", relay.Group);
Assert.Equal("", relay.Text);
Assert.Equal("N1KXJ-14 N:NEDR", relay.ToString());
}

[Fact]
public void ANoticeIsFromAnsrvrInAnyCase()
{
Assert.True(AnsrvrMessage.TryParse(Callsign.Parse("ansrvr"), "N:NEDR 2 Messages Sent", "HV", out var notice));
Assert.Equal(AnsrvrMessageKind.Notice, notice.Kind);
}

[Fact]
public void ARelayIsNotAMessageFromAnsrvrsOtherStations()
{
// An SSID of ANSRVR's is another station; with a message ID it's a person's message, without one a relay.
Assert.False(AnsrvrMessage.TryParse(Callsign.Parse("ANSRVR-1"), "N:NEDR 2 Messages Sent", "HV", out _));
Assert.True(AnsrvrMessage.TryParse(Callsign.Parse("ANSRVR-1"), "N:NEDR hi", null, out var relay));
Assert.Equal(AnsrvrMessageKind.Relay, relay.Kind);
}
}
Loading