mirror of
https://github.com/yaakov-h/aprsbot.git
synced 2024-11-21 12:34:56 +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