mirror of
https://github.com/yaakov-h/aprsbot.git
synced 2024-11-23 05:24:57 +00:00
Initial commit so I dont lose this code
This commit is contained in:
commit
fdb762725e
10 changed files with 552 additions and 0 deletions
25
aprsbot.sln
Normal file
25
aprsbot.sln
Normal 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
6
aprsbot/AprsHeader.cs
Normal file
|
@ -0,0 +1,6 @@
|
|||
using System.Collections.Immutable;
|
||||
|
||||
namespace AprsBot
|
||||
{
|
||||
record AprsHeader(string FromCall, ImmutableArray<string> Path);
|
||||
}
|
267
aprsbot/AprsIsTcpClient.cs
Normal file
267
aprsbot/AprsIsTcpClient.cs
Normal 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
4
aprsbot/AprsMessage.cs
Normal file
|
@ -0,0 +1,4 @@
|
|||
namespace AprsBot
|
||||
{
|
||||
record AprsMessage(string ToCall, string Text, string Id);
|
||||
}
|
37
aprsbot/AprsPacket.cs
Normal file
37
aprsbot/AprsPacket.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
34
aprsbot/AprsPacketExtensions.cs
Normal file
34
aprsbot/AprsPacketExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
27
aprsbot/AuthenticationFailureException.cs
Normal file
27
aprsbot/AuthenticationFailureException.cs
Normal 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
94
aprsbot/Program.cs
Normal 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
44
aprsbot/SpanCharExtensions.cs
Normal file
44
aprsbot/SpanCharExtensions.cs
Normal 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
14
aprsbot/aprsbot.csproj
Normal 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>
|
Loading…
Reference in a new issue