Initial commit so I dont lose this code

This commit is contained in:
Yaakov 2021-03-31 23:37:18 +11:00
commit fdb762725e
10 changed files with 552 additions and 0 deletions

25
aprsbot.sln Normal file
View file

@ -0,0 +1,25 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.31112.23
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "aprsbot", "aprsbot\aprsbot.csproj", "{34399D99-E6ED-4CDD-841F-4A7E29B93B5A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{34399D99-E6ED-4CDD-841F-4A7E29B93B5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{34399D99-E6ED-4CDD-841F-4A7E29B93B5A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{34399D99-E6ED-4CDD-841F-4A7E29B93B5A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{34399D99-E6ED-4CDD-841F-4A7E29B93B5A}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {34E2BD39-0208-4061-949B-C290907EAEAD}
EndGlobalSection
EndGlobal

6
aprsbot/AprsHeader.cs Normal file
View file

@ -0,0 +1,6 @@
using System.Collections.Immutable;
namespace AprsBot
{
record AprsHeader(string FromCall, ImmutableArray<string> Path);
}

267
aprsbot/AprsIsTcpClient.cs Normal file
View file

@ -0,0 +1,267 @@
using Microsoft.Extensions.Logging;
using MQTTnet;
using MQTTnet.Client.Options;
using System;
using System.Diagnostics;
using System.IO;
using System.Net.Sockets;
using System.Runtime.ExceptionServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace AprsBot
{
class AprsIsTcpClient : IAsyncDisposable
{
public AprsIsTcpClient(ILogger<AprsIsTcpClient> logger)
{
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
client = new TcpClient();
}
readonly ILogger logger;
readonly TcpClient client;
TaskCompletionSource<bool> authenticationTaskCompletionSource;
NetworkStream stream;
StreamReader reader;
StreamWriter writer;
Task loopTask;
string serverIdentification;
string user;
public async ValueTask DisposeAsync() => await DisconnectAsync(waitForTask: true);
public async ValueTask DisconnectAsync(bool waitForTask)
{
await writer.DisposeAsync();
reader.Dispose();
stream.Dispose();
client.Dispose();
if (waitForTask)
{
await loopTask.ConfigureAwait(false);
}
authenticationTaskCompletionSource = null;
}
public async Task ConnectAsync(string hostname, int port, CancellationToken cancellationToken = default)
{
await client.ConnectAsync(hostname, port, cancellationToken);
cancellationToken.Register(() => client.Close());
stream = client.GetStream();
writer = new StreamWriter(stream, Encoding.ASCII)
{
AutoFlush = true
};
reader = new StreamReader(stream, Encoding.ASCII);
var identificationLine = await reader.ReadLineAsync();
Debug.Assert(identificationLine.StartsWith("# "), "first message should be server ident");
serverIdentification = identificationLine[2..];
loopTask = Task.Run(async () => await MainLoopAsync(cancellationToken).ConfigureAwait(false), CancellationToken.None);
}
public async Task AuthenticateAsync(string user, string password, CancellationToken cancellationToken)
{
var newTcs = new TaskCompletionSource<bool>();
var oldTcs = Interlocked.CompareExchange(ref authenticationTaskCompletionSource, newTcs, null);
if (oldTcs != null)
{
throw new InvalidOperationException("AuthenticateAsync has already been called.");
}
cancellationToken.Register(() => newTcs.TrySetCanceled());
this.user = user;
await writer.WriteLineAsync($"user {user} pass {password}");
await newTcs.Task;
}
const int ERROR_OPERATION_ABORTED = 995;
async Task<string> ReadLineAsync(CancellationToken cancellationToken)
{
try
{
return await reader.ReadLineAsync();
}
catch (IOException ex) when (cancellationToken.IsCancellationRequested && ex.InnerException is SocketException { NativeErrorCode: ERROR_OPERATION_ABORTED })
{
cancellationToken.ThrowIfCancellationRequested();
throw null; // unreachable
}
catch (ObjectDisposedException) when (reader.BaseStream is NetworkStream { Socket: { Connected: false } })
{
// We can't wait for the main loop task because we are being executed inside the main loop task.
// If we wait for it then we will deadlock.
await DisconnectAsync(waitForTask: false);
return null;
}
}
async Task MainLoopAsync(CancellationToken cancellationToken)
{
var authenticated = false;
string line;
try
{
while ((line = await ReadLineAsync(cancellationToken)) != null)
{
if (!authenticated)
{
await HandlePreAuthMessageAsync(line, ref authenticated);
}
else
{
await HandlePostAuthMessageAsync(line, cancellationToken);
}
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
}
Task HandlePreAuthMessageAsync(string message, ref bool authenticated)
{
if (message.StartsWith($"# logresp {user} verified"))
{
authenticated = true;
logger.LogInformation("Authenticated as {user}.", user);
}
else if (message.StartsWith($"# logresp {user} unverified"))
{
var ex = new AuthenticationFailureException();
ExceptionDispatchInfo.SetCurrentStackTrace(ex);
authenticationTaskCompletionSource.TrySetException(ex);
}
else
{
logger.LogWarning("Unexpected pre-auth message: {message}", message);
}
return Task.CompletedTask;
}
async Task HandlePostAuthMessageAsync(string message, CancellationToken cancellationToken)
{
if (message.StartsWith("# "))
{
if (message.IndexOf(serverIdentification) == 2)
{
logger.LogDebug("Got server ping.");
return;
}
else
{
logger.LogWarning("Unhandled control message: '{message}'", message);
return;
}
}
if (!AprsPacket.TryParse(message, out var packet))
{
logger.LogWarning("Unhandled packet: '{message}'", message);
return;
}
if (packet.BodyText.Length == 0)
{
logger.LogWarning("Empty message body!");
return;
}
var packetType = packet.BodyText[0];
switch (packetType)
{
case ':':
await HandleAprsMessageAsync(packet, cancellationToken);
break;
default:
logger.LogWarning("Unsupported packet type: '{packetType}'", packetType);
break;
};
}
async Task HandleAprsMessageAsync(AprsPacket packet, CancellationToken cancellationToken)
{
if (!packet.TryParseAprsMessage(out var message))
{
return;
}
if (message.ToCall != user)
{
logger.LogWarning("Recieved message for another user '{user}'!", message.ToCall);
return;
}
logger.LogInformation("Message from {user}: {text}", packet.Header.FromCall, message.Text);
try
{
var factory = new MqttFactory();
using var client = factory.CreateMqttClient();
var result = await client.ConnectAsync(
new MqttClientOptionsBuilder()
.WithTcpServer(MqttSettings.Server)
.Build(),
cancellationToken);
await client.PublishAsync(new MqttApplicationMessageBuilder()
.WithTopic(MqttSettings.Topic)
.WithPayload("(oled:clear)")
.Build(),
cancellationToken);
await client.PublishAsync(new MqttApplicationMessageBuilder()
.WithTopic(MqttSettings.Topic)
.WithPayload($"(oled:text 0 30 {packet.Header.FromCall}:)")
.Build(),
cancellationToken);
await client.PublishAsync(new MqttApplicationMessageBuilder()
.WithTopic(MqttSettings.Topic)
.WithPayload($"(oled:text 0 20 {message.Text})")
.Build(),
cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Unhandled exception handling incoming APRS message.");
await RejectMessageAsync(packet, message, cancellationToken);
return;
}
await AcknowledgeMessageAsync(packet, message, cancellationToken);
}
// Note for APRS: All callsigns must be padded to 9 digits by adding trailing spaces.
async Task AcknowledgeMessageAsync(AprsPacket packet, AprsMessage message, CancellationToken cancellationToken)
=> await SendPacketAsync($":{packet.Header.FromCall,-9}:ack{message.Id}", cancellationToken);
async Task RejectMessageAsync(AprsPacket packet, AprsMessage message, CancellationToken cancellationToken)
=> await SendPacketAsync($":{packet.Header.FromCall,-9}:rej{message.Id}", cancellationToken);
async Task SendPacketAsync(string bodyText, CancellationToken cancellationToken)
{
var rawPacket = $"{user,-9}>APRS:{bodyText}";
await writer.WriteLineAsync(rawPacket.AsMemory(), cancellationToken);
}
}
}

4
aprsbot/AprsMessage.cs Normal file
View file

@ -0,0 +1,4 @@
namespace AprsBot
{
record AprsMessage(string ToCall, string Text, string Id);
}

37
aprsbot/AprsPacket.cs Normal file
View file

@ -0,0 +1,37 @@
using System;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
namespace AprsBot
{
record AprsPacket(AprsHeader Header, string BodyText)
{
// e.g.: "VK2BSD-5>APFII0,qAC,APRSFI::VK2BSD-XX:test message{21E3C";
public static bool TryParse(ReadOnlySpan<char> text, [NotNullWhen(true)] out AprsPacket packet)
{
var indexOfSeparator = text.IndexOf(':');
if (indexOfSeparator < 0)
{
packet = default;
return false;
}
var headerText = text[0..indexOfSeparator];
var bodyText = text[(indexOfSeparator + 1)..];
var indexOfArrow = headerText.IndexOf('>');
if (indexOfArrow < 0)
{
packet = default;
return false;
}
var fromCall = headerText[..indexOfArrow];
var path = headerText[(indexOfArrow + 1)..].Split(',');
var header = new AprsHeader(fromCall.ToString(), path);
packet = new AprsPacket(header, bodyText.ToString());
return true;
}
}
}

View file

@ -0,0 +1,34 @@
using System;
using System.Diagnostics.CodeAnalysis;
namespace AprsBot
{
static class AprsPacketExtensions
{
public static bool TryParseAprsMessage(this AprsPacket packet, [NotNullWhen(true)] out AprsMessage message)
{
var indexOfMessage = packet.BodyText.IndexOf(':', startIndex: 1);
if (indexOfMessage < 0)
{
Console.WriteLine($"Invalid APRS message (missing text): {packet.BodyText}");
message = null;
return false;
}
var indexOfId = packet.BodyText.IndexOf('{', startIndex: indexOfMessage + 1);
if (indexOfId < 0)
{
Console.WriteLine($"Invalid APRS message (missing id): {packet.BodyText}");
message = null;
return false;
}
var toCall = packet.BodyText[1..indexOfMessage];
var text = packet.BodyText[(indexOfMessage + 1)..indexOfId];
var id = packet.BodyText[(indexOfId + 1)..];
message = new AprsMessage(toCall, text, id);
return true;
}
}
}

View file

@ -0,0 +1,27 @@
using System;
using System.Runtime.Serialization;
namespace AprsBot
{
[Serializable]
public class AuthenticationFailureException : Exception
{
public AuthenticationFailureException()
{
}
public AuthenticationFailureException(string message)
: base(message)
{
}
public AuthenticationFailureException(string message, Exception inner)
: base(message, inner)
{
}
protected AuthenticationFailureException(SerializationInfo info, StreamingContext context)
: base(info, context)
{
}
}
}

94
aprsbot/Program.cs Normal file
View file

@ -0,0 +1,94 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace AprsBot
{
static class AprsSettings
{
// For the user to fill out (or to be at least made configurable at runtime)
// Note: This uses the APRS-IS system and is therefore for licenced amateur radio operators only.
public const string User = "N0CALL";
public const string Password = "";
// North America: noam.aprs2.net
// South America: soam.aprs2.net
// Europe & Africa: euro.aprs2.net
// Asia: asia.aprs2.net
// Oceania: aunz.aprs2.net
public const string Server = "aunz.aprs2.net";
public const int Port = 14580;
}
static class MqttSettings
{
// See docs at http://www.openhardwareconf.org/wiki/Swagbadge2021_MQTT#OLED_messages
public const string Server = "101.181.46.180";
public const string Topic = "public/esp32_<your id here>/0/in";
}
partial class Program
{
static bool cancelled;
static async Task Main()
{
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (sender, e) =>
{
// Ctrl+C should trigger the CancellationTokenSource so that we can perform a graceful
// shutdown, but only the first one. A second Ctrl+C should perform a hard shutdown of the app.
if (!cancelled)
{
cts.Cancel();
e.Cancel = true;
cancelled = true;
}
};
await using var serviceProvider = new ServiceCollection()
.AddLogging(lb => lb.AddSimpleConsole())
.AddScoped<AprsIsTcpClient>()
.BuildServiceProvider();
using var scope = serviceProvider.CreateScope();
// This is purely decorative so that we know Ctrl+C has registered.
// If the app hangs after this, something has deadlocked in the shutdown handlers.
cts.Token.Register(() =>
{
using var s = serviceProvider.CreateScope();
var logger = s.ServiceProvider.GetRequiredService<ILogger<Program>>();
logger.LogInformation("Shutting down...");
});
await using var client = serviceProvider.GetRequiredService<AprsIsTcpClient>();
try
{
cts.Token.ThrowIfCancellationRequested();
await client.ConnectAsync(AprsSettings.Server, AprsSettings.Port, cts.Token);
await client.AuthenticateAsync(AprsSettings.User, AprsSettings.Password, cts.Token);
var tcs = new TaskCompletionSource<object>();
cts.Token.Register(() => tcs.TrySetCanceled());
await tcs.Task;
}
catch (AuthenticationFailureException)
{
var logger = serviceProvider.GetRequiredService<ILogger<Program>>();
logger.LogError("Invalid username and/or password");
}
catch (OperationCanceledException) when (cts.Token.IsCancellationRequested)
{
}
}
}
}

View file

@ -0,0 +1,44 @@
using System;
using System.Collections.Immutable;
namespace AprsBot
{
static class SpanCharExtensions
{
public static ImmutableArray<string> Split(this ReadOnlySpan<char> text, char delimiter)
{
var builder = ImmutableArray.CreateBuilder<string>(initialCapacity: GetNumberOfOccurences(text, delimiter) + 1);
while (text.Length > 0)
{
var nextDelimiter = text.IndexOf(delimiter);
if (nextDelimiter < 0)
{
builder.Add(text.ToString());
text = text[^0..];
}
else
{
builder.Add(text[..nextDelimiter].ToString());
text = text[(nextDelimiter + 1)..];
}
}
return builder.MoveToImmutable();
}
static int GetNumberOfOccurences(ReadOnlySpan<char> text, char character)
{
var count = 0;
for (var i = 0; i < text.Length; i++)
{
if (text[i] == character)
{
count++;
}
}
return count;
}
}
}

14
aprsbot/aprsbot.csproj Normal file
View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="5.0.0" />
<PackageReference Include="MQTTnet" Version="3.0.15" />
</ItemGroup>
</Project>