commit fdb762725ee7ec079d0686522b086443d0d77b4e Author: Yaakov Date: Wed Mar 31 23:37:18 2021 +1100 Initial commit so I dont lose this code diff --git a/aprsbot.sln b/aprsbot.sln new file mode 100644 index 0000000..67aa809 --- /dev/null +++ b/aprsbot.sln @@ -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 diff --git a/aprsbot/AprsHeader.cs b/aprsbot/AprsHeader.cs new file mode 100644 index 0000000..46ea0b7 --- /dev/null +++ b/aprsbot/AprsHeader.cs @@ -0,0 +1,6 @@ +using System.Collections.Immutable; + +namespace AprsBot +{ + record AprsHeader(string FromCall, ImmutableArray Path); +} diff --git a/aprsbot/AprsIsTcpClient.cs b/aprsbot/AprsIsTcpClient.cs new file mode 100644 index 0000000..61967c1 --- /dev/null +++ b/aprsbot/AprsIsTcpClient.cs @@ -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 logger) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + client = new TcpClient(); + } + + readonly ILogger logger; + readonly TcpClient client; + + TaskCompletionSource 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(); + 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 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); + } + } +} diff --git a/aprsbot/AprsMessage.cs b/aprsbot/AprsMessage.cs new file mode 100644 index 0000000..789c474 --- /dev/null +++ b/aprsbot/AprsMessage.cs @@ -0,0 +1,4 @@ +namespace AprsBot +{ + record AprsMessage(string ToCall, string Text, string Id); +} diff --git a/aprsbot/AprsPacket.cs b/aprsbot/AprsPacket.cs new file mode 100644 index 0000000..36ab2a4 --- /dev/null +++ b/aprsbot/AprsPacket.cs @@ -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 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; + } + } +} diff --git a/aprsbot/AprsPacketExtensions.cs b/aprsbot/AprsPacketExtensions.cs new file mode 100644 index 0000000..81adef1 --- /dev/null +++ b/aprsbot/AprsPacketExtensions.cs @@ -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; + } + } +} diff --git a/aprsbot/AuthenticationFailureException.cs b/aprsbot/AuthenticationFailureException.cs new file mode 100644 index 0000000..b8a996c --- /dev/null +++ b/aprsbot/AuthenticationFailureException.cs @@ -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) + { + } + } +} diff --git a/aprsbot/Program.cs b/aprsbot/Program.cs new file mode 100644 index 0000000..54986c4 --- /dev/null +++ b/aprsbot/Program.cs @@ -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_/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() + .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>(); + logger.LogInformation("Shutting down..."); + }); + + await using var client = serviceProvider.GetRequiredService(); + + 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(); + cts.Token.Register(() => tcs.TrySetCanceled()); + + await tcs.Task; + } + catch (AuthenticationFailureException) + { + var logger = serviceProvider.GetRequiredService>(); + logger.LogError("Invalid username and/or password"); + } + catch (OperationCanceledException) when (cts.Token.IsCancellationRequested) + { + } + } + } +} diff --git a/aprsbot/SpanCharExtensions.cs b/aprsbot/SpanCharExtensions.cs new file mode 100644 index 0000000..afead0a --- /dev/null +++ b/aprsbot/SpanCharExtensions.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Immutable; + +namespace AprsBot +{ + static class SpanCharExtensions + { + public static ImmutableArray Split(this ReadOnlySpan text, char delimiter) + { + var builder = ImmutableArray.CreateBuilder(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 text, char character) + { + var count = 0; + for (var i = 0; i < text.Length; i++) + { + if (text[i] == character) + { + count++; + } + } + + return count; + } + } +} diff --git a/aprsbot/aprsbot.csproj b/aprsbot/aprsbot.csproj new file mode 100644 index 0000000..8dfa858 --- /dev/null +++ b/aprsbot/aprsbot.csproj @@ -0,0 +1,14 @@ + + + + Exe + net5.0 + + + + + + + + +