├── src ├── KissTnc │ ├── KissTnc.md │ ├── KissTnc.csproj │ ├── FrameReceivedEventArgs.cs │ ├── ISerialConnection.cs │ ├── SerialConnection.cs │ ├── SerialTnc.cs │ ├── TcpTnc.cs │ ├── KISSProtocolValues.cs │ └── Tnc.cs ├── APRSsharp │ ├── Properties │ │ └── launchSettings.json │ ├── Mode.cs │ └── APRSsharp.csproj ├── Shared │ ├── Shared.csproj │ ├── Utilities.cs │ ├── ITcpConnection.cs │ └── TcpConnection.cs ├── AprsParser │ ├── AprsParser.md │ ├── Enums │ │ ├── CoordinateSystem.cs │ │ ├── TimestampType.cs │ │ ├── NmeaType.cs │ │ └── PacketType.cs │ ├── AprsParser.csproj │ ├── InfoField │ │ ├── UnsupportedInfo.cs │ │ ├── MaidenheadBeaconInfo.cs │ │ ├── InfoField.cs │ │ ├── MessageInfo.cs │ │ ├── PositionInfo.cs │ │ └── StatusInfo.cs │ ├── Extensions │ │ ├── MatchExtensions.cs │ │ ├── WeatherExtensions.cs │ │ └── EnumConversionExtensions.cs │ ├── RegexStrings.cs │ └── Packet.cs └── AprsIsClient │ ├── ConnectionState.cs │ ├── AprsIsClient.csproj │ ├── AprsIsClient.md │ └── AprsIsClient.cs ├── .editorconfig ├── .github ├── pull_request_template.md ├── CODEOWNERS └── workflows │ ├── markdown.yml │ ├── build.yml │ ├── codeql-analysis.yml │ └── release.yml ├── test ├── AprsIsClientUnitTests │ ├── TimedTests.cs │ ├── AprsIsClientUnitTests.csproj │ ├── DisposeUnitTests.cs │ └── AprsIsClientUnitTests.cs ├── KissTncUnitTests │ ├── KissTncUnitTests.csproj │ ├── SerialTncUnitTests.cs │ ├── TcpTncUnitTests.cs │ └── BaseTncUnitTests.cs └── AprsParserUnitTests │ ├── AprsParserUnitTests.csproj │ ├── Extensions │ └── EnumConversionExtensionsUnitTests.cs │ ├── InfoField │ ├── InfoFieldUnitTests.cs │ ├── UnsupportedInfoUnitTests.cs │ ├── MaidenheadBeaconInfoUnitTests.cs │ ├── MessageInfoUnitTests.cs │ └── StatusInfoUnitTests.cs │ ├── NmeaTypeUnitTests.cs │ ├── PacketAx25UnitTests.cs │ └── TimestampUnitTests.cs ├── Packages.props ├── LICENSE ├── analyzers.ruleset ├── Directory.Build.props ├── CONTRIBUTING.md ├── README.md ├── CODE_OF_CONDUCT.md ├── .gitignore └── AprsSharp.sln /src/KissTnc/KissTnc.md: -------------------------------------------------------------------------------- 1 | # KISS TNC . - KISS Interface for Terminal Node Controller 2 | 3 | A serial interface for terminal node controllers (TNCs) which use the 4 | [Keep It Simple, Stupid (KISS) protocol](https://en.wikipedia.org/wiki/KISS_(TNC)) 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Don't want to ConfigureAwait(false) in application code 2 | # Also want to allow the test synchronization context to work, so don't do this in tests. 3 | [{src/APRSsharp/Program.cs,test/**.cs}] 4 | dotnet_diagnostic.CA2007.severity = none 5 | -------------------------------------------------------------------------------- /src/APRSsharp/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "APRSsharp": { 4 | "commandName": "Project", 5 | "commandLineArgs": "--mode TCPTNC --server localhost --port 8001", 6 | "nativeDebugging": true 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /src/Shared/Shared.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.1 5 | AprsSharp.Shared 6 | Shared code common to other AprsSharp packages. 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | _A brief description of the PR including motivation and strategy._ 4 | 5 | ## Changes 6 | 7 | * _Bullet points describing specific changes in this PR_ 8 | 9 | ## Validation 10 | 11 | * _Bullet points listing validation steps (e.g. manual testing, test passes, etc.)_ 12 | -------------------------------------------------------------------------------- /src/AprsParser/AprsParser.md: -------------------------------------------------------------------------------- 1 | # AprsParser 2 | 3 | APRS packet parser, encoder, and decoder. 4 | 5 | For more information about the APRS protocol, view the following: 6 | 7 | * [APRS Protocol Reference 1.01](http://aprs.org/doc/APRS101.PDF) 8 | * [APRS SPEC Addendum 1.1](http://aprs.org/aprs11.html) 9 | * [APRS SPEC Addendum 1.2 Proposals](http://aprs.org/aprs12.html) 10 | -------------------------------------------------------------------------------- /test/AprsIsClientUnitTests/TimedTests.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharpUnitTests.AprsIsClient 2 | { 3 | using Xunit; 4 | 5 | /// 6 | /// Test collection to disable test parallelism, which 7 | /// is required for set timeouts on tests. 8 | /// 9 | [CollectionDefinition(nameof(TimedTests), DisableParallelization = true)] 10 | public class TimedTests 11 | { 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Each line is a file pattern followed by one or more owners. 2 | # https://docs.github.com/en/free-pro-team@latest/github/creating-cloning-and-archiving-repositories/about-code-owners#codeowners-syntax 3 | 4 | # These owners will be the default owners for everything in 5 | # the repo. Unless a later match takes precedence, these will 6 | # be requested for review when someone opens a pull request 7 | * @CBielstein 8 | -------------------------------------------------------------------------------- /src/AprsParser/Enums/CoordinateSystem.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.AprsParser 2 | { 3 | /// 4 | /// Specifies whether a function is referring to latitude or longitude during Maidenhead gridsquare encode/decode. 5 | /// 6 | public enum CoordinateSystem 7 | { 8 | /// 9 | /// Latitute. 10 | /// 11 | Latitude, 12 | 13 | /// 14 | /// Longitude. 15 | /// 16 | Longitude, 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Packages.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | all 5 | runtime; build; native; contentfiles; analyzers 6 | 7 | 8 | all 9 | runtime; build; native; contentfiles; analyzers 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/APRSsharp/Mode.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.Applications.Console; 2 | 3 | /// 4 | /// The modes in which this program can operate. 5 | /// 6 | public enum Mode 7 | { 8 | /// 9 | /// Receives packets from APRS-IS 10 | /// 11 | APRSIS, 12 | 13 | /// 14 | /// Interfaces with a KISS TNC via TCP 15 | /// 16 | TCPTNC, 17 | 18 | /// 19 | /// Interfaces with a KISS TNC via serial connection 20 | /// 21 | SERIALTNC, 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/markdown.yml: -------------------------------------------------------------------------------- 1 | name: Markdown 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | paths: 12 | - '**/*.md' 13 | - '.github/workflows/**' 14 | 15 | jobs: 16 | lint: 17 | name: Lint 18 | timeout-minutes: 5 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: Clone 23 | uses: actions/checkout@v4 24 | 25 | - name: Lint 26 | uses: nosborn/github-action-markdown-cli@v3 27 | with: 28 | files: "**/*.md" 29 | -------------------------------------------------------------------------------- /src/AprsParser/AprsParser.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.1 5 | AprsSharp.AprsParser 6 | Library for encoding and decoding packets in the Automatic Packet Reporting System (APRS) 7 | ham radio;amateur radio;packet radio;radio;APRS;Automatic Packet Reporting System; 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /test/KissTncUnitTests/KissTncUnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | build: 14 | name: build 15 | timeout-minutes: 5 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Clone 20 | uses: actions/checkout@v4 21 | 22 | - name: DotnetVersion 23 | uses: actions/setup-dotnet@v4 24 | with: 25 | dotnet-version: '8.0.x' 26 | 27 | - name: restore 28 | run: dotnet restore 29 | 30 | - name: Builds 31 | run: dotnet build --configuration Release --no-restore 32 | 33 | - name: Test 34 | run: dotnet test --no-restore 35 | -------------------------------------------------------------------------------- /test/AprsParserUnitTests/AprsParserUnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/KissTnc/KissTnc.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.1 5 | AprsSharp.KissTnc 6 | Library for interfacing with Terminal Node Controllers (TNC) with the KISS protocol used frequently in the Automatic Packet Reporting System (APRS). 7 | ham radio;amateur radio;packet radio;radio;APRS;Automatic Packet Reporting System;kiss;tnc;Terminal Node Controller; 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /test/KissTncUnitTests/SerialTncUnitTests.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharpUnitTests.KissTnc; 2 | 3 | using AprsSharp.KissTnc; 4 | using Microsoft.VisualStudio.TestTools.UnitTesting; 5 | using Moq; 6 | 7 | /// 8 | /// Test class. 9 | /// 10 | [TestClass] 11 | public class SerialTncUnitTests : BaseTNCUnitTests 12 | { 13 | /// 14 | public override SerialTnc BuildTestTnc() 15 | { 16 | var mockPort = new Mock(); 17 | mockPort.SetupGet(mock => mock.IsOpen).Returns(false); 18 | mockPort.Setup(mock => mock.Open()); 19 | mockPort.Setup(mock => mock.Write(It.IsAny(), It.IsAny(), It.IsAny())); 20 | 21 | return new SerialTnc(mockPort.Object, 0); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/AprsIsClient/ConnectionState.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.AprsIsClient 2 | { 3 | /// 4 | /// Tracks the state of an . 5 | /// 6 | public enum ConnectionState 7 | { 8 | /// 9 | /// The connection has not yet been established. 10 | /// 11 | NotConnected, 12 | 13 | /// 14 | /// A connection has been made to the server. 15 | /// 16 | Connected, 17 | 18 | /// 19 | /// The server has accepted a login command. 20 | /// 21 | LoggedIn, 22 | 23 | /// 24 | /// The connection has been disconnected. 25 | /// 26 | Disconnected, 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/AprsParser/Enums/TimestampType.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.AprsParser 2 | { 3 | /// 4 | /// Represents the type of APRS timestamp. 5 | /// 6 | public enum TimestampType 7 | { 8 | /// 9 | /// Days/Hours/Minutes zulu 10 | /// 11 | DHMz, 12 | 13 | /// 14 | /// Days/Hours/Minutes local 15 | /// 16 | DHMl, 17 | 18 | /// 19 | /// Hours/Minutes/Seconds (always zulu) 20 | /// 21 | HMS, 22 | 23 | /// 24 | /// Hours/Minutes/Seconds (always zulu) 25 | /// 26 | MDHM, 27 | 28 | /// 29 | /// Not a decoded timestamp 30 | /// 31 | NotDecoded, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | analyze: 11 | name: Analyze 12 | runs-on: ubuntu-latest 13 | permissions: 14 | actions: read 15 | contents: read 16 | security-events: write 17 | 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | language: [ 'csharp' ] 22 | 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v4 26 | 27 | - name: Initialize CodeQL 28 | uses: github/codeql-action/init@v3 29 | with: 30 | languages: ${{ matrix.language }} 31 | 32 | - name: Autobuild 33 | uses: github/codeql-action/autobuild@v3 34 | 35 | - name: Perform CodeQL Analysis 36 | uses: github/codeql-action/analyze@v3 37 | -------------------------------------------------------------------------------- /test/KissTncUnitTests/TcpTncUnitTests.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharpUnitTests.KissTnc; 2 | 3 | using AprsSharp.KissTnc; 4 | using AprsSharp.Shared; 5 | using Microsoft.VisualStudio.TestTools.UnitTesting; 6 | using Moq; 7 | 8 | /// 9 | /// Test class. 10 | /// 11 | [TestClass] 12 | public class TcpTncUnitTests : BaseTNCUnitTests 13 | { 14 | /// 15 | public override TcpTnc BuildTestTnc() 16 | { 17 | var mockConn = new Mock(); 18 | mockConn.SetupGet(mock => mock.Connected).Returns(true); 19 | mockConn.Setup(mock => mock.AsyncReceive(It.IsAny())); 20 | mockConn.Setup(mock => mock.SendBytes(It.IsAny())); 21 | mockConn.Setup(mock => mock.SendString(It.IsAny())); 22 | 23 | return new TcpTnc(mockConn.Object, 0); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/KissTnc/FrameReceivedEventArgs.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.KissTnc 2 | { 3 | using System; 4 | using System.Collections.Generic; 5 | 6 | /// 7 | /// Caries the bytes delivered in a single frame by the TNC. 8 | /// 9 | public class FrameReceivedEventArgs : EventArgs 10 | { 11 | /// 12 | /// Initializes a new instance of the class. 13 | /// 14 | /// Data bytes received in the frame. 15 | public FrameReceivedEventArgs(byte[] bytes) 16 | { 17 | Data = bytes; 18 | } 19 | 20 | /// 21 | /// Gets the data bytes of the received frame. 22 | /// 23 | public IReadOnlyList Data 24 | { 25 | get; 26 | private set; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/AprsIsClient/AprsIsClient.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | netstandard2.1 10 | AprsSharp.AprsIsClient 11 | Library for sending and receiving packets through the Automatic Packet Reporting System Internet Service (APRS-IS) 12 | ham radio;amateur radio;packet radio;radio;APRS;Automatic Packet Reporting System;Automatic Packet Reporting System Internet System;APRS-IS; 13 | AprsIsClient.md 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /test/AprsIsClientUnitTests/AprsIsClientUnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/Shared/Utilities.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.Shared; 2 | 3 | using System.Reflection; 4 | 5 | /// 6 | /// Utilities and helpers methods used across AprsSharp. 7 | /// 8 | public static class Utilities 9 | { 10 | /// 11 | /// Returns the version of the assembly from the perspective of the caller. 12 | /// 13 | /// Note: Inclusion/exclusion of the source link part of the version depends on the 14 | /// IncludeSourceRevisionInInformationalVersion setting in the csproj of the 15 | /// calling class/project. 16 | /// 17 | /// The assembly version as a string. 18 | public static string GetAssemblyVersion() 19 | { 20 | var assemblyInfo = Assembly.GetCallingAssembly().GetCustomAttribute(); 21 | return assemblyInfo.InformationalVersion; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/APRSsharp/APRSsharp.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net8.0 6 | true 7 | true 8 | win-x64;linux-x64 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /test/AprsParserUnitTests/Extensions/EnumConversionExtensionsUnitTests.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharpUnitTests.AprsParser 2 | { 3 | using AprsSharp.AprsParser; 4 | using AprsSharp.AprsParser.Extensions; 5 | using Xunit; 6 | 7 | /// 8 | /// Tests code in the class. 9 | /// 10 | public class EnumConversionExtensionsUnitTests 11 | { 12 | /// 13 | /// Tests GetTypeChar. 14 | /// 15 | /// to test. 16 | /// Expected character result. 17 | [Theory] 18 | [InlineData(PacketType.PositionWithoutTimestampWithMessaging, '=')] 19 | public void PacketTypeConversion( 20 | PacketType packetType, 21 | char expectedChar) 22 | { 23 | Assert.Equal(expectedChar, packetType.ToChar()); 24 | Assert.Equal(packetType, expectedChar.ToPacketType()); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/AprsParserUnitTests/InfoField/InfoFieldUnitTests.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharpUnitTests.AprsParser 2 | { 3 | using AprsSharp.AprsParser; 4 | using Xunit; 5 | 6 | /// 7 | /// Tests code in the class related to encode/decode of the information field. 8 | /// 9 | public class InfoFieldUnitTests 10 | { 11 | /// 12 | /// Tests GetDataType. 13 | /// 14 | /// Input information field to test. 15 | /// Expected data type result. 16 | [Theory] 17 | [InlineData("/092345z4903.50N/07201.75W>Test1234", PacketType.PositionWithTimestampNoMessaging)] 18 | [InlineData(">IO91SX/G", PacketType.Status)] 19 | public void GetDataType( 20 | string informationField, 21 | PacketType expectedDataType) 22 | { 23 | InfoField infoField = InfoField.FromString(informationField); 24 | Assert.Equal(expectedDataType, infoField.Type); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/AprsParser/Enums/NmeaType.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.AprsParser 2 | { 3 | /// 4 | /// Used to specify format during decode of raw GPS data packets. 5 | /// 6 | public enum NmeaType 7 | { 8 | /// 9 | /// Not yet decoded 10 | /// 11 | NotDecoded, 12 | 13 | /// 14 | /// GGA: Global Position System Fix Data 15 | /// 16 | GGA, 17 | 18 | /// 19 | /// GLL: Geographic Position, Latitude/Longitude Data 20 | /// 21 | GLL, 22 | 23 | /// 24 | /// RMC: Recommended Minimum Specific GPS/Transit Data 25 | /// 26 | RMC, 27 | 28 | /// 29 | /// VTG: Velocity and Track Data 30 | /// 31 | VTG, 32 | 33 | /// 34 | /// WPT: Way Point Location (also WPL) 35 | /// 36 | WPT, 37 | 38 | /// 39 | /// Not supported/known type 40 | /// 41 | Unknown, 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Cameron Bielstein 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /analyzers.ruleset: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/KissTnc/ISerialConnection.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.KissTnc; 2 | 3 | using System; 4 | using System.IO.Ports; 5 | 6 | /// 7 | /// Abstracts a serial port connection. 8 | /// 9 | public interface ISerialConnection : IDisposable 10 | { 11 | /// 12 | /// Indicates that data has been received through the . 13 | /// 14 | event SerialDataReceivedEventHandler DataReceived; 15 | 16 | /// 17 | /// Gets a value indicating whether the is open or closed. 18 | /// 19 | bool IsOpen { get; } 20 | 21 | /// 22 | /// Opens a new serial port connection. 23 | /// 24 | void Open(); 25 | 26 | /// 27 | /// Writes a specified number of bytes to the serial port using data from a buffer. 28 | /// 29 | /// The byte array that contains the data to write to the port. 30 | /// The zero-based byte offset in the buffer parameter at which to begin copying bytes to the port. 31 | /// The number of bytes to write. 32 | void Write(byte[] buffer, int offset, int count); 33 | } 34 | -------------------------------------------------------------------------------- /src/KissTnc/SerialConnection.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.KissTnc; 2 | 3 | using System.IO.Ports; 4 | 5 | /// 6 | /// Wraps a object. 7 | /// 8 | public sealed class SerialConnection : ISerialConnection 9 | { 10 | private readonly SerialPort serialPort; 11 | 12 | /// 13 | /// Initializes a new instance of the class. 14 | /// 15 | /// The name of the to open. 16 | public SerialConnection(string portName) 17 | { 18 | serialPort = new SerialPort(portName); 19 | } 20 | 21 | /// 22 | public event SerialDataReceivedEventHandler DataReceived 23 | { 24 | add => serialPort.DataReceived += value; 25 | remove => serialPort.DataReceived -= value; 26 | } 27 | 28 | /// 29 | public bool IsOpen => serialPort.IsOpen; 30 | 31 | /// 32 | public void Dispose() 33 | { 34 | serialPort.Dispose(); 35 | } 36 | 37 | /// 38 | public void Open() => serialPort.Open(); 39 | 40 | /// 41 | public void Write(byte[] buffer, int offset, int count) => serialPort.Write(buffer, offset, count); 42 | } 43 | -------------------------------------------------------------------------------- /test/AprsIsClientUnitTests/DisposeUnitTests.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharpUnitTests.AprsIsClient 2 | { 3 | using AprsSharp.AprsIsClient; 4 | using AprsSharp.Shared; 5 | using Microsoft.Extensions.Logging.Abstractions; 6 | using Moq; 7 | using Xunit; 8 | 9 | /// 10 | /// Unit tests for . 11 | /// 12 | public class DisposeUnitTests 13 | { 14 | /// 15 | /// Ensures properly disposes 16 | /// the injected . 17 | /// 18 | /// `true` if the mock should be disposed by the 19 | /// under test. 20 | [Theory] 21 | [InlineData(true)] 22 | [InlineData(false)] 23 | public void ProperlyHandlesInjectedTcpConnection(bool clientShouldDispose) 24 | { 25 | var mockTcpConnection = new Mock(); 26 | 27 | using (AprsIsClient connection = new AprsIsClient(mockTcpConnection.Object, clientShouldDispose)) 28 | { 29 | } 30 | 31 | mockTcpConnection.Verify(mock => mock.Dispose(), clientShouldDispose ? Times.Once : Times.Never); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test/AprsParserUnitTests/NmeaTypeUnitTests.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharpUnitTests.AprsParser 2 | { 3 | using AprsSharp.AprsParser; 4 | using AprsSharp.AprsParser.Extensions; 5 | using Xunit; 6 | 7 | /// 8 | /// Tests and related code. 9 | /// 10 | public class NmeaTypeUnitTests 11 | { 12 | /// 13 | /// Tests GetType from a type identifier. 14 | /// 15 | /// Expected NmeaType returned by test. 16 | /// Input string to test. 17 | [Theory] 18 | [InlineData(NmeaType.GGA, "GGA")] 19 | [InlineData(NmeaType.Unknown, "POO")] 20 | [InlineData(NmeaType.WPT, "wpt")] 21 | [InlineData(NmeaType.WPT, "wpl")] 22 | public void GetTypeFromIdentifier(NmeaType expected, string input) 23 | { 24 | Assert.Equal(expected, input.ToNmeaType()); 25 | } 26 | 27 | /// 28 | /// Tests GetType from a full NMEA string. 29 | /// 30 | [Fact(Skip = "Issue #24: Fix skipped tests from old repository")] 31 | public void GetTypeFromRawString() 32 | { 33 | Assert.Equal(NmeaType.RMC, "$GPRMC,063909,A,3349.4302,N,11700.3721,W,43.022,89.3,291099,13.6,E*52".ToNmeaType()); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12.0 4 | enable 5 | true 6 | true 7 | $(MSBuildThisFileDirectory)\analyzers.ruleset 8 | 0.4.1 9 | Cameron Bielstein 10 | Cameron Bielstein 11 | https://github.com/CBielstein/APRSsharp 12 | https://github.com/CBielstein/APRSsharp 13 | git 14 | MIT 15 | Copyright (c) Cameron Bielstein 2020 16 | true 17 | true 18 | All 19 | false 20 | 21 | 22 | 23 | True 24 | True 25 | true 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/AprsParser/InfoField/UnsupportedInfo.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.AprsParser; 2 | 3 | using System; 4 | 5 | /// 6 | /// Represents an info field that is not supported by APRS#. 7 | /// 8 | public class UnsupportedInfo : InfoField 9 | { 10 | /// 11 | /// Initializes a new instance of the class. 12 | /// 13 | /// A string encoding of an . 14 | public UnsupportedInfo(string encodedInfoField) 15 | : base(encodedInfoField) 16 | { 17 | Content = encodedInfoField; 18 | } 19 | 20 | /// 21 | /// Gets the content. 22 | /// 23 | public string Content { get; } 24 | 25 | /// 26 | /// Not supported. is to help fill the gap on receive 27 | /// and should not be used for transmissions. We do not want to send non-standard packets 28 | /// and crowd the APRS airwaves. If you'd like to transmit a packet type that is not yet 29 | /// supported in APRS#, consider opening/commenting on an issue or sending a PR to 30 | /// implement the type. 31 | /// 32 | /// . 33 | /// Nothing, not supported. 34 | public override string Encode() => throw new NotSupportedException($"{nameof(UnsupportedInfo)} should not be used to encode packets for transmission. Please use a supported type."); 35 | } 36 | -------------------------------------------------------------------------------- /test/AprsParserUnitTests/PacketAx25UnitTests.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharpUnitTests.AprsParser; 2 | 3 | using System; 4 | using AprsSharp.AprsParser; 5 | using Xunit; 6 | 7 | /// 8 | /// Tests code for encoding and decoding AX.25 packet format. 9 | /// 10 | public class PacketAx25UnitTests 11 | { 12 | /// 13 | /// Tests a full roundtrip encode then decode in AX.25. 14 | /// 15 | [Fact] 16 | public void RoundTripEncodeDecode() 17 | { 18 | var sender = "N0CALL"; 19 | var destination = "N0NE"; 20 | var path = new string[2] { "WIDE2-2", "WIDE1-1" }; 21 | var info = new StatusInfo(new Timestamp(DateTime.UtcNow), "Testing 1 2 3!"); 22 | 23 | var packet = new Packet(sender, destination, path, info); 24 | 25 | var encoded = packet.EncodeAx25(); 26 | 27 | var decodedPacket = new Packet(encoded); 28 | 29 | Assert.Equal(sender, decodedPacket.Sender); 30 | Assert.Equal(destination, decodedPacket.Destination); 31 | Assert.Equal(path, decodedPacket.Path); 32 | 33 | var si = Assert.IsType(decodedPacket.InfoField); 34 | Assert.NotNull(si.Timestamp); 35 | 36 | Assert.Equal(DateTimeKind.Utc, si.Timestamp.DateTime.Kind); 37 | Assert.Equal(info.Timestamp!.DateTime.Day, si.Timestamp.DateTime.Day); 38 | Assert.Equal(info.Timestamp!.DateTime.Hour, si.Timestamp.DateTime.Hour); 39 | Assert.Equal(info.Timestamp!.DateTime.Minute, si.Timestamp.DateTime.Minute); 40 | Assert.Equal(info.Comment, si.Comment); 41 | Assert.Equal(info.Position?.Coordinates, si.Position?.Coordinates); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/AprsIsClientUnitTests/AprsIsClientUnitTests.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharpUnitTests.AprsIsClient 2 | { 3 | using System.Threading.Tasks; 4 | using AprsSharp.AprsIsClient; 5 | using AprsSharp.Shared; 6 | using Microsoft.Extensions.Logging.Abstractions; 7 | using Moq; 8 | using Xunit; 9 | 10 | /// 11 | /// Tests . 12 | /// 13 | public class AprsIsClientUnitTests 14 | { 15 | /// 16 | /// Tests that the Disconnect() method on 17 | /// succesfully stops receiving and the Receive() Task returns. 18 | /// 19 | [Fact] 20 | public async void TestDisconnect() 21 | { 22 | Mock mockTcpConnection = new Mock(); 23 | mockTcpConnection.SetupGet(mock => mock.Connected).Returns(true); 24 | using AprsIsClient client = new AprsIsClient(mockTcpConnection.Object); 25 | 26 | Task receiveTask = client.Receive("callsign", "password", "server", "filter"); 27 | 28 | // Sleep 0.01 seconds to ensure the connection starts "receiving" 29 | await Task.Delay(10); 30 | 31 | client.Disconnect(); 32 | 33 | // TODO Issue 125: Add a timeout for this test, if disconnect fails we could be here a while... 34 | await receiveTask; 35 | 36 | Assert.True(receiveTask.IsCompletedSuccessfully, "Task did not complete successfully."); 37 | mockTcpConnection.Verify(mock => mock.Connect(It.IsAny(), It.IsAny()), Times.Once()); 38 | mockTcpConnection.Verify(mock => mock.ReceiveString(), Times.AtLeastOnce()); 39 | mockTcpConnection.Verify(mock => mock.Disconnect(), Times.Once()); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/AprsParser/Extensions/MatchExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.AprsParser.Extensions 2 | { 3 | using System; 4 | using System.Text.RegularExpressions; 5 | 6 | /// 7 | /// Extension methods for . 8 | /// 9 | public static class MatchExtensions 10 | { 11 | /// 12 | /// Asserts success of a Regex.Match call. 13 | /// If the Match object is not successful, throws an ArgumentException. 14 | /// 15 | /// Match object to check for success. 16 | /// Type that was being checked against the regex. 17 | /// The name of the param that did not match the regex. 18 | public static void AssertSuccess(this Match match, string type, string paramName) 19 | { 20 | if (match == null) 21 | { 22 | throw new ArgumentNullException(nameof(match)); 23 | } 24 | else if (!match.Success) 25 | { 26 | throw new ArgumentException($"{type} did not match regex", paramName); 27 | } 28 | } 29 | 30 | /// 31 | /// Asserts success of a Regex.Match call. 32 | /// If the Match object is not successful, throws an ArgumentException. 33 | /// 34 | /// Match object to check for success. 35 | /// that was being checked against the regex. 36 | /// The name of the param that did not match the regex. 37 | public static void AssertSuccess(this Match match, PacketType type, string paramName) 38 | { 39 | match.AssertSuccess(type.ToString(), paramName); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Shared/ITcpConnection.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.Shared 2 | { 3 | using System; 4 | 5 | /// 6 | /// Delegate for handling bytes received by this TCP connection. 7 | /// 8 | /// Bytes received. 9 | public delegate void HandleReceivedBytes(byte[] bytes); 10 | 11 | /// 12 | /// Abstracts a TCP connection. 13 | /// 14 | public interface ITcpConnection : IDisposable 15 | { 16 | /// 17 | /// Gets a value indicating whether this connection 18 | /// is currently connected to a server. 19 | /// 20 | public bool Connected { get; } 21 | 22 | /// 23 | /// Opens a connection. 24 | /// 25 | /// Server. 26 | /// Port. 27 | void Connect(string server, int port); 28 | 29 | /// 30 | /// Receives a full line. 31 | /// 32 | /// Message as a string. 33 | string ReceiveString(); 34 | 35 | /// 36 | /// Sends a full line. 37 | /// 38 | /// Message to send. 39 | void SendString(string message); 40 | 41 | /// 42 | /// Sends the contents of a byte array. 43 | /// 44 | /// Bytes to be sent. 45 | void SendBytes(byte[] bytes); 46 | 47 | /// 48 | /// The function to stop the receipt/cancel tcp packets. 49 | /// 50 | void Disconnect(); 51 | 52 | /// 53 | /// Begins async IO receive and accepts callback to handle new bytes. 54 | /// 55 | /// Delegate to handle received bytes. 56 | void AsyncReceive(HandleReceivedBytes callback); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | release: 5 | types: 6 | - created 7 | 8 | jobs: 9 | release: 10 | name: release 11 | timeout-minutes: 5 12 | runs-on: ubuntu-latest 13 | env: 14 | NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} 15 | 16 | steps: 17 | - name: Clone 18 | uses: actions/checkout@v4 19 | 20 | - name: DotnetVersion 21 | uses: actions/setup-dotnet@v4 22 | with: 23 | dotnet-version: '8.0.x' 24 | 25 | - name: Restore 26 | run: dotnet restore 27 | 28 | - name: Build 29 | run: dotnet build --configuration Release --no-restore 30 | 31 | - name: Test 32 | run: dotnet test --configuration Release --no-build 33 | 34 | - name: Pack AprsSharp.AprsParser 35 | run: dotnet pack src/AprsParser/AprsParser.csproj --configuration Release --no-build 36 | 37 | - name: Pack AprsSharp.AprsIsClient 38 | run: dotnet pack src/AprsIsClient/AprsIsClient.csproj --configuration Release --no-build 39 | 40 | - name: Pack AprsSharp.KissTnc 41 | run: dotnet pack src/KissTnc/KissTnc.csproj --configuration Release --no-build 42 | 43 | - name: Pack AprsSharp.Shared 44 | run: dotnet pack src/Shared/Shared.csproj --configuration Release --no-build 45 | 46 | - name: Publish AprsSharp.AprsParser 47 | run: dotnet nuget push src/AprsParser/bin/Release/AprsSharp.AprsParser.*.nupkg --api-key $NUGET_API_KEY --source https://api.nuget.org/v3/index.json 48 | 49 | - name: Publish AprsSharp.AprsIsClient 50 | run: dotnet nuget push src/AprsIsClient/bin/Release/AprsSharp.AprsIsClient.*.nupkg --api-key $NUGET_API_KEY --source https://api.nuget.org/v3/index.json 51 | 52 | - name: Publish AprsSharp.KissTnc 53 | run: dotnet nuget push src/KissTnc/bin/Release/AprsSharp.KissTnc.*.nupkg --api-key $NUGET_API_KEY --source https://api.nuget.org/v3/index.json 54 | 55 | - name: Publish AprsSharp.Shared 56 | run: dotnet nuget push src/Shared/bin/Release/AprsSharp.Shared.*.nupkg --api-key $NUGET_API_KEY --source https://api.nuget.org/v3/index.json 57 | -------------------------------------------------------------------------------- /src/AprsParser/Extensions/WeatherExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.AprsParser.Extensions 2 | { 3 | using System; 4 | using System.Globalization; 5 | 6 | /// 7 | /// Extension methods for handling weather packets. 8 | /// 9 | public static class WeatherExtensions 10 | { 11 | /// 12 | /// Determines if a 's symbol is a weather station. 13 | /// 14 | /// A to check. 15 | /// True if the 's symbol is a weather station, else false. 16 | public static bool IsWeatherSymbol(this Position position) 17 | { 18 | if (position == null) 19 | { 20 | throw new ArgumentNullException(nameof(position)); 21 | } 22 | 23 | return (position.SymbolTableIdentifier == '/' || position.SymbolTableIdentifier == '\\') && position.SymbolCode == '_'; 24 | } 25 | 26 | /// 27 | /// Returns a string encoding of a single measurement for a complete weather packet. 28 | /// 29 | /// The measurement to convert to string. 30 | /// The number of characters to use in this representation. 31 | /// The integer as a string or all dots if null. 32 | public static string ToWeatherEncoding(this int? measurement, int encodingLength = 3) 33 | { 34 | if (measurement == null) 35 | { 36 | return new string('.', encodingLength); 37 | } 38 | 39 | string encoded = Math.Abs(measurement.Value).ToString(CultureInfo.InvariantCulture).PadLeft(encodingLength, '0'); 40 | 41 | // If negative, ensure the negative sign is at the front of the padding zeros instead of in the middle 42 | // that's why we take the absolute value above, so that we can put the negative sign all the way at the front. 43 | if (measurement < 0) 44 | { 45 | encoded = $"-{encoded.Substring(1)}"; 46 | } 47 | 48 | return encoded; 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Code of Conduct 4 | 5 | Creating a welcoming and inclusive environment is important to the creation of 6 | a healthy open source project. 7 | To that end, all contributions are bound by the 8 | [code of conduct](CODE_OF_CONDUCT.md) in this repository. 9 | 10 | Above all, please be kind and courteous. 11 | 12 | In addition, please keep in mind that the maintainer(s) of this repository may 13 | have families, jobs, hobbies, or other responsibilities and responses might be 14 | slow. Please be patient. 15 | 16 | ## Contributing with Issues and Discussion 17 | 18 | Please feel free to open, comment on, or otherwise be involved with 19 | [issues in this repository](https://github.com/CBielstein/APRSsharp/issues). 20 | All bug reports, feature suggestions, user input, debugging, and more are more 21 | than welcome. 22 | 23 | ## Contribution with code 24 | 25 | ### Terms of Contributed Code 26 | 27 | Please note that by both industry convention and 28 | [GitHub policy](https://help.github.com/en/github/site-policy/github-terms-of-service#6-contributions-under-repository-license), 29 | your code is contributed in the same [LICENSE](LICENSE) as the rest of the repository. 30 | 31 | ### How to Contribute Code 32 | 33 | If you would like to contribute code to this repository, please use the 34 | following method: 35 | 36 | 1. Search for a relevant issue first to ensure you are not duplicate work with 37 | another individual. If there is an issue for the change you would like, 38 | consider commenting there and offering to help or adding your input. 39 | 1. If there is no relevant issue, open an issue of your own with the change you 40 | would like to make. Include details on motivation, design, user impact, and 41 | other relevant information. This gives the community a chance to discuss and 42 | give feedback early in the process. 43 | 1. Follow the [GitHub pull request process](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-requests) 44 | to create a branch, make your changes, and submit a pull request. 45 | 1. Your pull request will be reviewed for code quality, consistency, and 46 | functionality by community members and repository maintainers. This process is 47 | in place to help us all improve, so please strive to give and take feedback in 48 | a positive manner. 49 | 1. If approved, your code will be merged in to the repository and your issue 50 | may then be marked as resolved. Congratulations! 51 | 52 | Pull requests must meet the following requirements: 53 | 54 | * Code must successfully build and pass all tests in the branch 55 | * The PR must be approved by at least one repository maintainer. 56 | -------------------------------------------------------------------------------- /src/KissTnc/SerialTnc.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.KissTnc 2 | { 3 | using System; 4 | using System.IO.Ports; 5 | 6 | /// 7 | /// Represents an interface through a serial connection to a TNC using the KISS protocol. 8 | /// 9 | public sealed class SerialTnc : Tnc 10 | { 11 | /// 12 | /// The serial port to which the TNC is connected. 13 | /// 14 | private readonly ISerialConnection serialPort; 15 | 16 | /// 17 | /// Initializes a new instance of the class. 18 | /// 19 | /// The SerialPort to use for connection to the TNC. 20 | /// The port on the TNC used for communication. 21 | public SerialTnc(ISerialConnection serialPort, byte tncPort) 22 | : base(tncPort) 23 | { 24 | if (serialPort == null) 25 | { 26 | throw new ArgumentNullException(nameof(serialPort)); 27 | } 28 | else if (serialPort.IsOpen) 29 | { 30 | throw new ArgumentException("Port should not yet be opened", nameof(serialPort)); 31 | } 32 | 33 | this.serialPort = serialPort; 34 | this.serialPort.Open(); 35 | this.serialPort.DataReceived += new SerialDataReceivedEventHandler(HandleDataFromSerialPort); 36 | } 37 | 38 | /// 39 | protected override void Dispose(bool disposing) 40 | { 41 | base.Dispose(disposing); 42 | } 43 | 44 | /// 45 | protected override void SendToTnc(byte[] bytes) 46 | { 47 | if (bytes == null) 48 | { 49 | throw new ArgumentNullException(nameof(bytes)); 50 | } 51 | 52 | serialPort.Write(bytes, 0, bytes.Length); 53 | } 54 | 55 | /// 56 | /// Handle the DataReceived event from the serialPort. 57 | /// 58 | /// SerialPort which has received data. 59 | /// Additional args. 60 | private void HandleDataFromSerialPort(object sender, SerialDataReceivedEventArgs args) 61 | { 62 | SerialPort sp = (SerialPort)sender; 63 | 64 | int numBytesToRead = sp.BytesToRead; 65 | byte[] bytesReceived = new byte[numBytesToRead]; 66 | int numBytesWereRead = sp.Read(bytesReceived, 0, numBytesToRead); 67 | 68 | if (numBytesWereRead < numBytesToRead) 69 | { 70 | Array.Resize(ref bytesReceived, numBytesWereRead); 71 | } 72 | 73 | DecodeReceivedData(bytesReceived); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/KissTnc/TcpTnc.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.KissTnc 2 | { 3 | using System; 4 | using AprsSharp.Shared; 5 | 6 | /// 7 | /// Represents an interface through a TCP/IP connection to a TNC using the KISS protocol. 8 | /// 9 | public sealed class TcpTnc : Tnc 10 | { 11 | [System.Diagnostics.CodeAnalysis.SuppressMessage( 12 | "IDisposableAnalyzers.Correctness", 13 | "IDISP008:Don't assign member with injected and created disposables", 14 | Justification = "Used for testing, disposal managed by the tcpConnectionInjected bool.")] 15 | private readonly ITcpConnection tcpConnection; 16 | private readonly bool tcpConnectionInjected; 17 | 18 | /// 19 | /// Initializes a new instance of the class. 20 | /// 21 | /// A to communicate with the TNC. 22 | /// Port the remote TNC should use to communicate to the radio. Part of the KISS protocol. 23 | public TcpTnc(ITcpConnection tcpConnection, byte tncPort) 24 | : base(tncPort) 25 | { 26 | tcpConnectionInjected = true; 27 | this.tcpConnection = tcpConnection ?? throw new ArgumentNullException(nameof(tcpConnection)); 28 | 29 | if (!this.tcpConnection.Connected) 30 | { 31 | throw new ArgumentException("TCP connection is not yet connected.", nameof(tcpConnection)); 32 | } 33 | 34 | this.tcpConnection.AsyncReceive(bytes => DecodeReceivedData(bytes)); 35 | } 36 | 37 | /// 38 | /// Initializes a new instance of the class. 39 | /// 40 | /// The address (e.g. IP or domain name) of the TCP server. 41 | /// The TCP port for connection to the TCP server. 42 | /// The TNC port used for connection to the radio. Part of the KISS protocol. 43 | public TcpTnc(string address, int tcpPort, byte tncPort) 44 | : base(tncPort) 45 | { 46 | tcpConnectionInjected = false; 47 | tcpConnection = new TcpConnection(); 48 | tcpConnection.Connect(address, tcpPort); 49 | 50 | tcpConnection.AsyncReceive(bytes => DecodeReceivedData(bytes)); 51 | } 52 | 53 | /// 54 | protected override void Dispose(bool disposing) 55 | { 56 | if (disposing) 57 | { 58 | if (tcpConnectionInjected == false) 59 | { 60 | tcpConnection.Dispose(); 61 | } 62 | } 63 | 64 | base.Dispose(disposing); 65 | } 66 | 67 | /// 68 | protected override void SendToTnc(byte[] bytes) 69 | { 70 | if (bytes == null) 71 | { 72 | throw new ArgumentNullException(nameof(bytes)); 73 | } 74 | 75 | tcpConnection.SendBytes(bytes); 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/AprsParser/InfoField/MaidenheadBeaconInfo.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.AprsParser 2 | { 3 | using System; 4 | using System.Text; 5 | using System.Text.RegularExpressions; 6 | using AprsSharp.AprsParser.Extensions; 7 | 8 | /// 9 | /// Represents an info field for packet type . 10 | /// 11 | public class MaidenheadBeaconInfo : InfoField 12 | { 13 | /// 14 | /// Initializes a new instance of the class. 15 | /// NOTE: This packet type is considere obsolete and is included for decoding legacy devices. Use a status report instead. 16 | /// 17 | /// A string encoding of a . 18 | public MaidenheadBeaconInfo(string encodedInfoField) 19 | : base(encodedInfoField) 20 | { 21 | if (string.IsNullOrWhiteSpace(encodedInfoField)) 22 | { 23 | throw new ArgumentNullException(nameof(encodedInfoField)); 24 | } 25 | 26 | if (Type != PacketType.MaidenheadGridLocatorBeacon) 27 | { 28 | throw new ArgumentException($"Packet encoding not of type {nameof(PacketType.MaidenheadGridLocatorBeacon)}. Type was {Type}", nameof(encodedInfoField)); 29 | } 30 | 31 | Match match = Regex.Match(encodedInfoField, RegexStrings.MaidenheadGridLocatorBeacon); 32 | match.AssertSuccess(PacketType.Status, nameof(encodedInfoField)); 33 | 34 | Position = new Position(); 35 | Position.DecodeMaidenhead(match.Groups[1].Value); 36 | 37 | if (match.Groups[2].Success) 38 | { 39 | Comment = match.Groups[2].Value; 40 | } 41 | } 42 | 43 | /// 44 | /// Initializes a new instance of the class. 45 | /// 46 | /// A position, which will be encoded as a maidenhead gridsquare locator. 47 | /// An optional comment. 48 | public MaidenheadBeaconInfo(Position position, string? comment) 49 | : base(PacketType.MaidenheadGridLocatorBeacon) 50 | { 51 | Comment = comment; 52 | Position = position ?? throw new ArgumentNullException(nameof(position)); 53 | } 54 | 55 | /// 56 | /// Gets the packet comment. 57 | /// 58 | public string? Comment { get; } 59 | 60 | /// 61 | /// Gets the position from which the message was sent. 62 | /// 63 | public Position Position { get; } 64 | 65 | /// 66 | public override string Encode() 67 | { 68 | StringBuilder encoded = new StringBuilder(); 69 | 70 | encoded.Append($"[{Position.EncodeGridsquare(6, false)}]"); 71 | 72 | if (!string.IsNullOrEmpty(Comment)) 73 | { 74 | encoded.Append(Comment); 75 | } 76 | 77 | return encoded.ToString(); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/AprsIsClient/AprsIsClient.md: -------------------------------------------------------------------------------- 1 | # AprsSharp.AprsIsClient 2 | 3 | `AprsSharp.AprsIsClient` is part of the [AprsSharp Project](https://github.com/CBielstein/APRSsharp). 4 | `AprsIsClient` aims to be a simple library for interacting with the Automatic 5 | Packet Reporting System-Internet Service. 6 | 7 | APRS-IS uses TCP messages and specific encodings to communicate amateur radio packets. 8 | This library abstracts those aspects away. 9 | 10 | Currently, only receiving packets is implemented. 11 | The only outgoing communication to the server is that required for 12 | authorization and configuration. 13 | Packets cannot be sent via APRS-IS at this time. 14 | 15 | ## Getting started 16 | 17 | This package can be installed via [nuget.org](https://www.nuget.org): 18 | [AprsSharp.AprsIsClient on nuget.org](https://www.nuget.org/packages/AprsSharp.AprsIsClient). 19 | 20 | A simple meethod is via the command line: 21 | 22 | ```bash 23 | dotnet add package AprsSharp.AprsIsClient 24 | ``` 25 | 26 | ## Usage 27 | 28 | AprsIsClient uses an event driven framework. 29 | Major steps are to create a client, define callbacks, and then begin receiving. 30 | See the code snippet below for examples. 31 | 32 | ```csharp 33 | using AprsSharp.AprsIsClient; 34 | 35 | // Create a client 36 | using AprsIsClient aprsIs = new AprsIsClient(); 37 | 38 | // Print the sender and type of packet received. 39 | aprsIs.ReceivedPacket += packet => { 40 | Console.WriteLine(packet.Sender); 41 | Console.WriteLine(packet.InfoField.Type); 42 | }; 43 | 44 | // Optionally, you can print when the server connection changes state. 45 | aprsIs.ChangedState += state => 46 | Console.WriteLine($"State: {state} on server {aprsIs.ConnectedServer}"); 47 | 48 | // If you'd rather read the TCP messages directly, you can subscribe to those events 49 | aprsIs.ReceivedTcpMessage += message => 50 | Console.WriteLine($"New TCP message: {message}"); 51 | 52 | // And if you'd like to see error messages, you can subscribe to those 53 | aprsIs.DecodeFailed += (ex, message) => 54 | Console.WriteLine($"Failed to decode message {message} with exception {ex}"); 55 | 56 | // Once you've configured your events, begin receiving! 57 | // Assuming `callsign` and `password` are variables, this connects to 58 | // a server on the main rotation and requests all packets of 59 | // type == message (t/m). You can read more about filter commands here: 60 | // https://www.aprs-is.net/javAPRSFilter.aspx 61 | // 62 | // The `await` here is optional and may not be desirable if you're expecting 63 | // additional input. If you're writing a simple script, the await is helpful 64 | // to ensure your application doesn't terminate. The await will not return 65 | // until AprsIsClient is instructed to stop receiving. 66 | await aprsIs.Receive(callsign, password, "rotate.aprs2.net", "t/m"); 67 | ``` 68 | 69 | ## Additional documentation 70 | 71 | For more information, you can visit: 72 | 73 | * [AprsSharp on GitHub](https://github.com/CBielstein/APRSsharp) 74 | * APRS-IS documentation on [aprs-is.net](https://www.aprs-is.net/) 75 | * APRS documentation on [aprs.org](https://www.aprs.org/) 76 | 77 | ## Feedback 78 | 79 | Bugs, feedback, and feature requests can be filed via 80 | [GitHub Issues on the AprsSharp repository](https://github.com/CBielstein/APRSsharp/issues). 81 | -------------------------------------------------------------------------------- /src/KissTnc/KISSProtocolValues.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.KissTnc 2 | { 3 | /// 4 | /// Represents special characters in the KISS protocol. 5 | /// All values and descriptions taken from KA9Q's KISS paper. 6 | /// http://www.ka9q.net/papers/kiss.html. 7 | /// 8 | public enum SpecialCharacter 9 | { 10 | /// 11 | /// Frame End 12 | /// 13 | FEND = 0xC0, 14 | 15 | /// 16 | /// Frame Escape 17 | /// 18 | FESC = 0xDB, 19 | 20 | /// 21 | /// Transposed Frame End 22 | /// 23 | TFEND = 0xDC, 24 | 25 | /// 26 | /// Transposed Frame Escape 27 | /// 28 | TFESC = 0xDD, 29 | } 30 | 31 | /// 32 | /// Represents codes used to command the TNC in the KISS protocol. 33 | /// These are embedded in the encoding of frames sent to the TNC. 34 | /// 35 | public enum Command 36 | { 37 | /// 38 | /// The rest of the frame is data to be sent on the HDLC channel. 39 | /// 40 | DataFrame = 0, 41 | 42 | /// 43 | /// The next byte is the transmitter keyup delay in 10 ms units. 44 | /// The default start-up value is 50 (i.e., 500 ms). 45 | /// 46 | TxDelay = 1, 47 | 48 | /// 49 | /// The next byte is the persistence parameter, p, scaled to the 50 | /// range of 0 - 255 with the folloing formula: P = p * 256 - 1 51 | /// The default value is P = 63 (i.e. p = 0.25). 52 | /// 53 | P = 2, 54 | 55 | /// 56 | /// The next byte is the slot interval in 10 ms units. 57 | /// The default is 10 (i.e. 100ms). 58 | /// 59 | SlotTime = 3, 60 | 61 | /// 62 | /// The next byte is the time to hold up the TX after the FCS has been sent, 63 | /// in 10 MS units. This command is obsolete, and is included here only for 64 | /// compatibility with some existing implementations. 65 | /// 66 | TxTail = 4, 67 | 68 | /// 69 | /// The next byte is 0 for half duplex, nonzero for full duplex. 70 | /// The default is 0 (i.e., half duplex). 71 | /// 72 | FullDuplex = 5, 73 | 74 | /// 75 | /// Specific for each TNC. In the TNC-1, this command sets the modem speed. 76 | /// Other implementations may use this function for other hardware-specific functions. 77 | /// 78 | SetHardware = 6, 79 | 80 | /// 81 | /// Exit KISS and return control to a higher-level program. This is useful 82 | /// only when KISS is incorporated in to the TNC along with other applications. 83 | /// 84 | RETURN = 0xFF, 85 | } 86 | 87 | /// 88 | /// Used to set duplex mode. 89 | /// 90 | public enum DuplexState 91 | { 92 | /// 93 | /// Half duplex 94 | /// 95 | HalfDuplex, 96 | 97 | /// 98 | /// Full duplex 99 | /// 100 | FullDuplex, 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /test/AprsParserUnitTests/InfoField/UnsupportedInfoUnitTests.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharpUnitTests.AprsParser; 2 | 3 | using System; 4 | using System.Linq; 5 | using AprsSharp.AprsParser; 6 | using Xunit; 7 | 8 | /// 9 | /// Tests code in the class for displaying unsupported/unknown formats. 10 | /// 11 | public class UnsupportedInfoUnitTests 12 | { 13 | /// 14 | /// Verifies a (currently) unsupported packet type results in an . 15 | /// NOTE: This will have to be changed when that type is actually supported. 16 | /// This is currently a packet of "Item Report Format — with Lat/Long position". 17 | /// 18 | [Fact] 19 | public void TestUnsupportedType() 20 | { 21 | // "Item Report Format — with Lat/Long position" example from APRS 1.01 Spec 22 | string encoded = "N0CALL>WIDE2-2:)AID #2!4903.50N/07201.75WA"; 23 | Packet p = new Packet(encoded); 24 | 25 | Assert.Equal("N0CALL", p.Sender); 26 | Assert.Equal("WIDE2-2", p.Path.Single()); 27 | Assert.True((p.ReceivedTime - DateTime.UtcNow) < TimeSpan.FromMinutes(1)); 28 | Assert.Equal(PacketType.Item, p.InfoField.Type); 29 | Assert.IsType(p.InfoField); 30 | 31 | var ui = p.InfoField as UnsupportedInfo; 32 | Assert.Equal(")AID #2!4903.50N/07201.75WA", ui!.Content); 33 | } 34 | 35 | /// 36 | /// Verifies an invalid packet type results in an . 37 | /// 38 | [Fact] 39 | public void TestInvalidDataType() 40 | { 41 | // "Invalid Data / Test Data Format" example from APRS 1.01 Spec 42 | string encoded = "N0CALL>WIDE2-2:,191146,V,4214.2466,N,07303.5181,W,417.238,114.5,091099,14.7,W/GPS FIX"; 43 | Packet p = new Packet(encoded); 44 | 45 | Assert.Equal("N0CALL", p.Sender); 46 | Assert.Equal("WIDE2-2", p.Path.Single()); 47 | Assert.True((p.ReceivedTime - DateTime.UtcNow) < TimeSpan.FromMinutes(1)); 48 | Assert.Equal(PacketType.InvalidOrTestData, p.InfoField.Type); 49 | Assert.IsType(p.InfoField); 50 | 51 | var ui = p.InfoField as UnsupportedInfo; 52 | Assert.Equal(",191146,V,4214.2466,N,07303.5181,W,417.238,114.5,091099,14.7,W/GPS FIX", ui!.Content); 53 | } 54 | 55 | /// 56 | /// Verifies that a completely unknown encoding results in an . 57 | /// 58 | [Fact] 59 | public void TestUnknownEncoding() 60 | { 61 | string encoded = "N0CALL>WIDE2-2:EXAMPLE UNKNOWN ENCODING"; 62 | Packet p = new Packet(encoded); 63 | 64 | Assert.Equal("N0CALL", p.Sender); 65 | Assert.Equal("WIDE2-2", p.Path.Single()); 66 | Assert.True((p.ReceivedTime - DateTime.UtcNow) < TimeSpan.FromMinutes(1)); 67 | Assert.Equal(PacketType.Unknown, p.InfoField.Type); 68 | Assert.IsType(p.InfoField); 69 | 70 | var ui = p.InfoField as UnsupportedInfo; 71 | Assert.Equal("EXAMPLE UNKNOWN ENCODING", ui!.Content); 72 | } 73 | 74 | /// 75 | /// Verifies that trying to encode an results in an exception. 76 | /// 77 | [Fact] 78 | public void EncodeUnknownThrows() 79 | { 80 | UnsupportedInfo ui = new UnsupportedInfo("Some data"); 81 | Packet p = new Packet("N0CALL", Array.Empty(), ui); 82 | Assert.Throws(() => p.EncodeTnc2()); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/AprsParser/Enums/PacketType.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.AprsParser 2 | { 3 | /// 4 | /// The APRS packet type. 5 | /// 6 | public enum PacketType 7 | { 8 | /// 9 | /// Current Mic-E Data (Rev 0 beta) 10 | /// 11 | CurrentMicEData, 12 | 13 | /// 14 | /// Old Mic-E Data (Rev 0 beta) 15 | /// 16 | OldMicEData, 17 | 18 | /// 19 | /// Position without timestamp (no APRS messaging), or Ultimeter 2000 WX Station 20 | /// 21 | PositionWithoutTimestampNoMessaging, 22 | 23 | /// 24 | /// Peet Bros U-II Weather Station 25 | /// 26 | PeetBrosUIIWeatherStation, 27 | 28 | /// 29 | /// Raw GPS data or Ultimeter 2000 30 | /// 31 | RawGPSData, 32 | 33 | /// 34 | /// Agrelo DFJr / MicroFinder 35 | /// 36 | AgreloDFJrMicroFinder, 37 | 38 | /// 39 | /// [Reserved - Map Feature] 40 | /// 41 | MapFeature, 42 | 43 | /// 44 | /// Old Mic-E Data (but Current data for TM-D700) 45 | /// 46 | OldMicEDataCurrentTMD700, 47 | 48 | /// 49 | /// Item 50 | /// 51 | Item, 52 | 53 | /// 54 | /// [Reserved - shelter data with time] 55 | /// 56 | ShelterDataWithTime, 57 | 58 | /// 59 | /// Invalid data or test data 60 | /// 61 | InvalidOrTestData, 62 | 63 | /// 64 | /// [Reserved - Space Weather] 65 | /// 66 | SpaceWeather, 67 | 68 | /// 69 | /// Unused 70 | /// 71 | Unused, 72 | 73 | /// 74 | /// Position with timestamp (no APRS messaging) 75 | /// 76 | PositionWithTimestampNoMessaging, 77 | 78 | /// 79 | /// Message 80 | /// 81 | Message, 82 | 83 | /// 84 | /// Object 85 | /// 86 | [field:System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA1720", Justification = "APRS spec uses the word 'object', so it is appropriate here")] 87 | Object, 88 | 89 | /// 90 | /// Station Capabilities 91 | /// 92 | StationCapabilities, 93 | 94 | /// 95 | /// Position without timestamp (with APRS messaging) 96 | /// 97 | PositionWithoutTimestampWithMessaging, 98 | 99 | /// 100 | /// Status 101 | /// 102 | Status, 103 | 104 | /// 105 | /// Query 106 | /// 107 | Query, 108 | 109 | /// 110 | /// [Do not use] 111 | /// 112 | DoNotUse, 113 | 114 | /// 115 | /// Positionwith timestamp (with APRS messaging) 116 | /// 117 | PositionWithTimestampWithMessaging, 118 | 119 | /// 120 | /// Telemetry data 121 | /// 122 | TelemetryData, 123 | 124 | /// 125 | /// Maidenhead grid locator beacon (obsolete) 126 | /// 127 | MaidenheadGridLocatorBeacon, 128 | 129 | /// 130 | /// Weather Report (without position) 131 | /// 132 | WeatherReport, 133 | 134 | /// 135 | /// Current Mic-E Data (not used in TM-D700) 136 | /// 137 | CurrentMicEDataNotTMD700, 138 | 139 | /// 140 | /// User-Defined APRS packet format 141 | /// 142 | UserDefinedAPRSPacketFormat, 143 | 144 | /// 145 | /// [Do not use - TNC stream switch character] 146 | /// 147 | DoNotUseTNSStreamSwitchCharacter, 148 | 149 | /// 150 | /// Third-party traffic 151 | /// 152 | ThirdPartyTraffic, 153 | 154 | /// 155 | /// Not a recognized symbol 156 | /// 157 | Unknown, 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/AprsParser/InfoField/InfoField.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.AprsParser 2 | { 3 | using System; 4 | using AprsSharp.AprsParser.Extensions; 5 | 6 | /// 7 | /// A representation of an info field on an APRS packet. 8 | /// 9 | public abstract class InfoField 10 | { 11 | /// 12 | /// Initializes a new instance of the class. 13 | /// 14 | public InfoField() 15 | { 16 | Type = PacketType.Unknown; 17 | } 18 | 19 | /// 20 | /// Initializes a new instance of the class from an encoded string. 21 | /// 22 | /// An encoded InfoField from which to pull the Type. 23 | public InfoField(string encodedInfoField) 24 | { 25 | if (encodedInfoField == null) 26 | { 27 | throw new ArgumentNullException(nameof(encodedInfoField)); 28 | } 29 | 30 | Type = GetPacketType(encodedInfoField); 31 | } 32 | 33 | /// 34 | /// Initializes a new instance of the class from a . 35 | /// 36 | /// The of this . 37 | public InfoField(PacketType type) 38 | { 39 | Type = type; 40 | } 41 | 42 | /// 43 | /// Initializes a new instance of the class from another . 44 | /// This is the copy constructor. 45 | /// 46 | /// An to copy. 47 | public InfoField(InfoField infoField) 48 | { 49 | if (infoField == null) 50 | { 51 | throw new ArgumentNullException(nameof(infoField)); 52 | } 53 | 54 | Type = infoField.Type; 55 | } 56 | 57 | /// 58 | /// Gets or sets the of this packet. 59 | /// 60 | public PacketType Type { get; protected set; } 61 | 62 | /// 63 | /// Instantiates a type of from the given string. 64 | /// 65 | /// String representation of the APRS info field. 66 | /// A class extending . 67 | public static InfoField FromString(string encodedInfoField) 68 | { 69 | PacketType type = GetPacketType(encodedInfoField); 70 | 71 | switch (type) 72 | { 73 | case PacketType.PositionWithoutTimestampNoMessaging: 74 | case PacketType.PositionWithoutTimestampWithMessaging: 75 | case PacketType.PositionWithTimestampNoMessaging: 76 | case PacketType.PositionWithTimestampWithMessaging: 77 | PositionInfo positionInfo = new PositionInfo(encodedInfoField); 78 | return positionInfo.Position.IsWeatherSymbol() ? new WeatherInfo(positionInfo) : positionInfo; 79 | 80 | case PacketType.Status: 81 | return new StatusInfo(encodedInfoField); 82 | 83 | case PacketType.MaidenheadGridLocatorBeacon: 84 | return new MaidenheadBeaconInfo(encodedInfoField); 85 | 86 | case PacketType.Message: 87 | return new MessageInfo(encodedInfoField); 88 | 89 | default: 90 | return new UnsupportedInfo(encodedInfoField); 91 | } 92 | } 93 | 94 | /// 95 | /// Encodes an APRS info field to a string. 96 | /// 97 | /// String representation of the packet. 98 | public abstract string Encode(); 99 | 100 | /// 101 | /// Gets the of a string representation of an APRS info field. 102 | /// 103 | /// A string-encoded APRS info field. 104 | /// of the info field. 105 | private static PacketType GetPacketType(string encodedInfoField) 106 | { 107 | if (encodedInfoField == null) 108 | { 109 | throw new ArgumentNullException(nameof(encodedInfoField)); 110 | } 111 | 112 | // TODO Issue #67: This isn't always true. 113 | // '!' can come up to the 40th position. 114 | char dataTypeIdentifier = char.ToUpperInvariant(encodedInfoField[0]); 115 | 116 | return dataTypeIdentifier.ToPacketType(); 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Shared/TcpConnection.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.Shared 2 | { 3 | using System; 4 | using System.IO; 5 | using System.Net.Sockets; 6 | using System.Text; 7 | 8 | /// 9 | /// Represents a TcpConnection. 10 | /// 11 | public sealed class TcpConnection : ITcpConnection 12 | { 13 | /// 14 | /// End-of-line bytes that servers expect at the end of a message. 15 | /// 16 | private static byte[] eolBytes = Encoding.ASCII.GetBytes("\r\n"); 17 | private readonly TcpClient tcpClient = new TcpClient(); 18 | private NetworkStream? stream; 19 | private StreamWriter? writer; 20 | private StreamReader? reader; 21 | 22 | /// 23 | public bool Connected => tcpClient.Connected; 24 | 25 | /// 26 | public void Connect(string server, int port) 27 | { 28 | tcpClient.Connect(server, port); 29 | stream?.Dispose(); 30 | stream = tcpClient.GetStream(); 31 | writer?.Dispose(); 32 | writer = new StreamWriter(stream) 33 | { 34 | NewLine = "\r\n", 35 | AutoFlush = true, 36 | }; 37 | reader?.Dispose(); 38 | reader = new StreamReader(stream); 39 | } 40 | 41 | /// 42 | public void Dispose() 43 | { 44 | writer?.Dispose(); 45 | reader?.Dispose(); 46 | stream?.Dispose(); 47 | tcpClient?.Dispose(); 48 | } 49 | 50 | /// 51 | public string ReceiveString() 52 | { 53 | if (reader == null) 54 | { 55 | throw new Exception(); 56 | } 57 | 58 | return reader.ReadLine(); 59 | } 60 | 61 | /// 62 | public void SendString(string message) 63 | { 64 | var bytes = Encoding.ASCII.GetBytes(message); 65 | SendBytes(bytes); 66 | } 67 | 68 | /// 69 | public void SendBytes(byte[] bytes) 70 | { 71 | if (writer == null) 72 | { 73 | throw new Exception(); 74 | } 75 | 76 | writer.BaseStream.Write(bytes); 77 | writer.BaseStream.Write(eolBytes); 78 | } 79 | 80 | /// 81 | public void Disconnect() 82 | { 83 | stream?.Close(); 84 | tcpClient.Close(); 85 | } 86 | 87 | /// 88 | public void AsyncReceive(HandleReceivedBytes callback) 89 | { 90 | var buffer = new byte[10]; 91 | 92 | if (stream?.CanRead != true) 93 | { 94 | throw new InvalidOperationException("Cannot read from NetworkStream."); 95 | } 96 | 97 | var receiveState = new ReceiveState(stream, buffer, callback); 98 | 99 | stream.BeginRead(buffer, 0, buffer.Length, HandleReceive, receiveState); 100 | } 101 | 102 | /// 103 | /// Handles receive of new bytes by invoking the callback and continuing receive. 104 | /// 105 | /// AsyncResult carrying the ReceiveState of this async IO. 106 | private static void HandleReceive(IAsyncResult asyncResult) 107 | { 108 | var rs = (ReceiveState)asyncResult.AsyncState; 109 | 110 | var bytesRead = rs.Stream.EndRead(asyncResult); 111 | var receivedBytes = rs.Buffer.AsSpan(0, bytesRead).ToArray(); 112 | 113 | rs.Callback(receivedBytes); 114 | 115 | rs.Stream.BeginRead(rs.Buffer, 0, rs.Buffer.Length, HandleReceive, rs); 116 | } 117 | 118 | /// 119 | /// Private class to track the state of async IO. 120 | /// 121 | private class ReceiveState 122 | { 123 | public ReceiveState(NetworkStream stream, byte[] buffer, HandleReceivedBytes callback) 124 | { 125 | Stream = stream; 126 | Buffer = buffer; 127 | Callback = callback; 128 | } 129 | 130 | /// 131 | /// Gets or sets the NetworkStream being used for receive. 132 | /// 133 | public NetworkStream Stream { get; set; } 134 | 135 | /// 136 | /// Gets or sets the buffer being used to store recieved bytes. 137 | /// 138 | public byte[] Buffer { get; set; } 139 | 140 | /// 141 | /// Gets or sets the callback to invoke when bytes are received. 142 | /// 143 | public HandleReceivedBytes Callback { get; set; } 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /test/AprsParserUnitTests/InfoField/MaidenheadBeaconInfoUnitTests.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharpUnitTests.AprsParser 2 | { 3 | using System; 4 | using System.Linq; 5 | using AprsSharp.AprsParser; 6 | using GeoCoordinatePortable; 7 | using Xunit; 8 | 9 | /// 10 | /// Tests code in the class related to encode/decode of the information field. 11 | /// 12 | public class MaidenheadBeaconInfoUnitTests 13 | { 14 | /// 15 | /// Verifies decoding and re-encoding a full status packet in TNC2 format. 16 | /// 17 | [Fact] 18 | public void TestRoundTrip() 19 | { 20 | string encoded = "N0CALL>WIDE1-1,WIDE2-2:[IO91SX]35 miles NNW of London"; 21 | Packet p = new Packet(encoded); 22 | 23 | Assert.Equal("N0CALL", p.Sender); 24 | Assert.Equal(2, p.Path.Count); 25 | Assert.Equal("WIDE1-1", p.Path.First()); 26 | Assert.Equal("WIDE2-2", p.Path.Last()); 27 | Assert.True((p.ReceivedTime - DateTime.UtcNow) < TimeSpan.FromMinutes(1)); 28 | Assert.Equal(PacketType.MaidenheadGridLocatorBeacon, p.InfoField.Type); 29 | 30 | if (p.InfoField is MaidenheadBeaconInfo mbi) 31 | { 32 | Assert.NotNull(mbi.Position); 33 | 34 | // Coordinates not precise when coming from gridhead 35 | double latitude = mbi.Position.Coordinates.Latitude; 36 | double longitude = mbi.Position.Coordinates.Longitude; 37 | Assert.Equal(51.98, Math.Round(latitude, 2)); 38 | Assert.Equal(-0.46, Math.Round(longitude, 2)); 39 | 40 | Assert.Equal("IO91SX", mbi.Position.EncodeGridsquare(6, false)); 41 | Assert.Equal('\\', mbi.Position.SymbolTableIdentifier); 42 | Assert.Equal('.', mbi.Position.SymbolCode); 43 | Assert.Equal("35 miles NNW of London", mbi.Comment); 44 | } 45 | else 46 | { 47 | Assert.IsType(p.InfoField); 48 | } 49 | 50 | Assert.Equal(encoded, p.EncodeTnc2()); 51 | } 52 | 53 | /// 54 | /// Tests decoding a Maidenhead Locator Beacon based on the APRS spec. 55 | /// 56 | /// Information field for decode. 57 | /// Expected comment. 58 | [Theory] 59 | [InlineData("[IO91SX] 35 miles NNW of London", " 35 miles NNW of London")] 60 | [InlineData("[IO91SX]", null)] 61 | public void DecodeMaidenheadLocatorBeacon(string informationField, string? expectedComment) 62 | { 63 | MaidenheadBeaconInfo mbi = new MaidenheadBeaconInfo(informationField); 64 | Assert.NotNull(mbi.Position); 65 | Assert.Equal(51.98, Math.Round(mbi.Position.Coordinates.Latitude, 2)); 66 | Assert.Equal(-0.46, Math.Round(mbi.Position.Coordinates.Longitude, 2)); 67 | Assert.Equal(expectedComment, mbi.Comment); 68 | } 69 | 70 | /// 71 | /// Tests encoding a Maidenhead Locator Beacon based on examples from the APRS spec. 72 | /// 73 | /// Packet comment. 74 | /// Expected encoding. 75 | [Theory] 76 | [InlineData("35 miles NNW of London", "[IO91SX]35 miles NNW of London")] // With comment 77 | [InlineData("", "[IO91SX]")] // Without comment 78 | public void EncodeMaidenheadLocatorBeaconFromLatLong( 79 | string comment, 80 | string expectedEncoding) 81 | { 82 | Position p = new Position(new GeoCoordinate(51.98, -0.46)); 83 | MaidenheadBeaconInfo mbi = new MaidenheadBeaconInfo(p, comment); 84 | Assert.Equal(expectedEncoding, mbi.Encode()); 85 | } 86 | 87 | /// 88 | /// Tests encoding a Maidenhead Locator Beacon based on the APRS spec from a Maidenhead position. 89 | /// 90 | /// Packet comment. 91 | /// Expected encoding. 92 | [Theory] 93 | [InlineData(" 35 miles NNW of London", "[IO91SX] 35 miles NNW of London")] // With comment 94 | [InlineData("", "[IO91SX]")] // Without comment 95 | public void EncodeMaidenheadLocatorBeaconFromMaidenhead( 96 | string comment, 97 | string expectedEncoding) 98 | { 99 | Position p = new Position(); 100 | p.DecodeMaidenhead("IO91SX"); 101 | MaidenheadBeaconInfo mbi = new MaidenheadBeaconInfo(p, comment); 102 | Assert.Equal(expectedEncoding, mbi.Encode()); 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /test/AprsParserUnitTests/InfoField/MessageInfoUnitTests.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharpUnitTests.AprsParser 2 | { 3 | using System; 4 | using System.Linq; 5 | using AprsSharp.AprsParser; 6 | using Xunit; 7 | 8 | /// 9 | /// Tests code in the class related to encode/decode of position reports. 10 | /// 11 | public class MessageInfoUnitTests 12 | { 13 | /// 14 | /// Verifies decoding and re-encoding a full message packet. 15 | /// 16 | [Fact] 17 | public void TestRoundTrip() 18 | { 19 | string encoded = "N0CALL>WIDE2-2::Test :Test Message{123ab"; 20 | Packet p = new Packet(encoded); 21 | 22 | Assert.Equal("N0CALL", p.Sender); 23 | Assert.Equal("WIDE2-2", p.Path.Single()); 24 | Assert.True((p.ReceivedTime - DateTime.UtcNow) < TimeSpan.FromMinutes(1)); 25 | Assert.Equal(PacketType.Message, p.InfoField.Type); 26 | 27 | if (p.InfoField is MessageInfo mi) 28 | { 29 | Assert.Equal("Test", mi.Addressee); 30 | Assert.Equal("Test Message", mi.Content); 31 | Assert.Equal("123ab", mi.Id); 32 | } 33 | else 34 | { 35 | Assert.IsType(p.InfoField); 36 | } 37 | 38 | Assert.Equal(encoded, p.EncodeTnc2()); 39 | } 40 | 41 | /// 42 | /// Tests decoding a message info field based on the APRS spec. 43 | /// 44 | /// Information field for decode. 45 | /// Expected decoded addressee. 46 | /// Expected decoded content. 47 | /// Expected decoded message ID. 48 | [Theory] 49 | [InlineData(":WU2Z :Testing", "WU2Z", "Testing", null)] // From APRS 1.01 50 | [InlineData(":WU2Z :Testing{003", "WU2Z", "Testing", "003")] // From APRS 1.01 51 | [InlineData(":EMAIL :msproul@ap.org Test email", "EMAIL", "msproul@ap.org Test email", null)] // From APRS 1.01 52 | [InlineData(":Testing12:{3", "Testing12", null, "3")] // No content 53 | public void DecodeMessageFormat( 54 | string informationField, 55 | string expectedAddressee, 56 | string expectedContent, 57 | string? expectedId) 58 | { 59 | MessageInfo mi = new MessageInfo(informationField); 60 | 61 | Assert.Equal(expectedAddressee, mi.Addressee); 62 | Assert.Equal(expectedContent, mi.Content); 63 | Assert.Equal(expectedId, mi.Id); 64 | } 65 | 66 | /// 67 | /// Tests encoding a message info field based on the APRS spec. 68 | /// 69 | /// Expected decoded addressee. 70 | /// Expected decoded content. 71 | /// Expected decoded message ID. 72 | /// Information field for decode. 73 | [Theory] 74 | [InlineData("WU2Z", "Testing", null, ":WU2Z :Testing")] // From APRS 1.01 75 | [InlineData("WU2Z", "Testing", "003", ":WU2Z :Testing{003")] // From APRS 1.01 76 | [InlineData("EMAIL", "msproul@ap.org Test email", null, ":EMAIL :msproul@ap.org Test email")] // From APRS 1.01 77 | [InlineData("Testing12", null, "3", ":Testing12:{3")] // No content 78 | public void EncodeMessageFormat( 79 | string addressee, 80 | string? content, 81 | string? id, 82 | string expectedEncoding) 83 | { 84 | MessageInfo mi = new MessageInfo(addressee, content, id); 85 | Assert.Equal(expectedEncoding, mi.Encode()); 86 | } 87 | 88 | /// 89 | /// Validates that message ID must be alphanumeric. 90 | /// 91 | [Fact] 92 | public void NonAlphanumericIdRejected() 93 | { 94 | var exception = Assert.Throws(() => new MessageInfo("test", "test", "!@#$%")); 95 | Assert.Equal("If provided, ID must be only alphanumeric (Parameter 'messageId')", exception.Message); 96 | } 97 | 98 | /// 99 | /// Validates that disallowed characters in content result in exception. 100 | /// 101 | /// Content to test. 102 | [Theory] 103 | [InlineData("Test{")] 104 | [InlineData("Test~")] 105 | [InlineData("Test|")] 106 | public void DisallowedContentCharactersRejected(string content) 107 | { 108 | var exception = Assert.Throws(() => new MessageInfo("test", content, null)); 109 | Assert.Equal("Message content may not include `|`, `~`, or `{` (Parameter 'content')", exception.Message); 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/AprsParser/Extensions/EnumConversionExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.AprsParser.Extensions 2 | { 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | 7 | /// 8 | /// Extension methods for conversion of enum types. 9 | /// 10 | public static class EnumConversionExtensions 11 | { 12 | /// 13 | /// Maps a type-identifying character to a packet . 14 | /// 15 | private static readonly Dictionary DataTypeMap = new Dictionary() 16 | { 17 | { (char)0x1c, PacketType.CurrentMicEData }, 18 | { (char)0x1d, PacketType.OldMicEData }, 19 | { '!', PacketType.PositionWithoutTimestampNoMessaging }, 20 | { '\"', PacketType.Unused }, 21 | { '#', PacketType.PeetBrosUIIWeatherStation }, 22 | { '$', PacketType.RawGPSData }, 23 | { '%', PacketType.AgreloDFJrMicroFinder }, 24 | { '&', PacketType.MapFeature }, 25 | { '\'', PacketType.OldMicEDataCurrentTMD700 }, 26 | { '(', PacketType.Unused }, 27 | { ')', PacketType.Item }, 28 | { '*', PacketType.PeetBrosUIIWeatherStation }, 29 | { '+', PacketType.ShelterDataWithTime }, 30 | { ',', PacketType.InvalidOrTestData }, 31 | { '-', PacketType.Unused }, 32 | { '.', PacketType.SpaceWeather }, 33 | { '/', PacketType.PositionWithTimestampNoMessaging }, 34 | { ':', PacketType.Message }, 35 | { ';', PacketType.Object }, 36 | { '<', PacketType.StationCapabilities }, 37 | { '=', PacketType.PositionWithoutTimestampWithMessaging }, 38 | { '>', PacketType.Status }, 39 | { '?', PacketType.Query }, 40 | { '@', PacketType.PositionWithTimestampWithMessaging }, 41 | { 'T', PacketType.TelemetryData }, 42 | { '[', PacketType.MaidenheadGridLocatorBeacon }, 43 | { '\\', PacketType.Unused }, 44 | { ']', PacketType.Unused }, 45 | { '^', PacketType.Unused }, 46 | { '_', PacketType.WeatherReport }, 47 | { '`', PacketType.CurrentMicEDataNotTMD700 }, 48 | { '{', PacketType.UserDefinedAPRSPacketFormat }, 49 | { '}', PacketType.ThirdPartyTraffic }, 50 | { 'A', PacketType.DoNotUse }, 51 | { 'S', PacketType.DoNotUse }, 52 | { 'U', PacketType.DoNotUse }, 53 | { 'Z', PacketType.DoNotUse }, 54 | { '0', PacketType.DoNotUse }, 55 | { '9', PacketType.DoNotUse }, 56 | }; 57 | 58 | /// 59 | /// Converts a char to its corresponding . 60 | /// 61 | /// A char representation of . 62 | /// of the info field, if not a valid mapping. 63 | public static PacketType ToPacketType(this char typeIdentifier) => DataTypeMap.GetValueOrDefault(char.ToUpperInvariant(typeIdentifier), PacketType.Unknown); 64 | 65 | /// 66 | /// Converts to the char representation of a given . 67 | /// 68 | /// to represent. 69 | /// A char representing type. 70 | public static char ToChar(this PacketType type) => DataTypeMap.First(pair => pair.Value == type).Key; 71 | 72 | /// 73 | /// Determines the of a raw GPS packet determined by a string. 74 | /// If the string is length 3, the three letters are taken as is. 75 | /// If the string is length 6 or longer, the indentifier is expected in the place dictated by the NMEA formats. 76 | /// 77 | /// String of length 3 identifying a raw GPS type or an entire NMEA string. 78 | /// The raw GPS represented by the argument. 79 | public static NmeaType ToNmeaType(this string nmeaInput) 80 | { 81 | if (nmeaInput == null) 82 | { 83 | throw new ArgumentNullException(nameof(nmeaInput)); 84 | } 85 | else if (nmeaInput.Length != 3 && nmeaInput.Length < 6) 86 | { 87 | throw new ArgumentException("rawGpsIdentifier should be exactly 3 characters or at least 6. Given: " + nmeaInput.Length, nameof(nmeaInput)); 88 | } 89 | else if (nmeaInput.Length >= 6) 90 | { 91 | throw new NotImplementedException(); 92 | } 93 | 94 | return nmeaInput.ToUpperInvariant() switch 95 | { 96 | "GGA" => NmeaType.GGA, 97 | "GLL" => NmeaType.GLL, 98 | "RMC" => NmeaType.RMC, 99 | "VTG" => NmeaType.VTG, 100 | "WPT" => NmeaType.WPT, 101 | "WPL" => NmeaType.WPT, 102 | _ => NmeaType.Unknown, 103 | }; 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # APRS# (APRSsharp) 2 | 3 | APRS# - Modern APRS software for the amateur radio community 4 | 5 | ## About APRS 6 | 7 | The [Automatic Packet Reporting System](https://en.wikipedia.org/wiki/Automatic_Packet_Reporting_System) 8 | is a digital wireless network designed and run by [amateur (ham) radio](https://en.wikipedia.org/wiki/Amateur_radio) 9 | operators around the world. Introduced in 1980, it is an ad-hoc, multi-hop, 10 | distributed network through which packets are transmitted and retransmitted 11 | between nodes to increase range. Data transmitted through APRS includes 12 | positional information, personal messages, weather station readings, search and 13 | rescue locations, and more. APRS packets are encoded and decoded by amateur 14 | radio devices, specialized hardware or software, or a combination. 15 | 16 | ## APRS# Project 17 | 18 | The protocol has been in use since its introduction in the 1980s 19 | ([see the site by the original creator here](http://aprs.org/)) and has seen 20 | various incarnations of encode/decode/visualize software. This project exists 21 | to eventually become a modern user interface for sending, receiving, and 22 | visualizing APRS digital packets in a straight-forward manner. 23 | 24 | This project is provided as [open source](LICENSE) and developed by the 25 | community for the community. 26 | 27 | ### Further Documentation 28 | 29 | See supplemental documentation for APRS# constituent projects: 30 | 31 | * [AprsParser](src/AprsParser/AprsParser.md) 32 | * [KissTnc](src/KissTnc/KissTnc.md) 33 | * [AprsIsClient](src/AprsIsClient/AprsIsClient.md) 34 | 35 | ## Note on Versioning 36 | 37 | Currently, all packages from this repo change version together. 38 | Although that isn't fully ideal, it is easy and works for now. 39 | 40 | The goal is for v1.0.0 to come when the APRS v1 spec is implemented, then 41 | increment up from there. 42 | 43 | Until then, this packages from this repo use a slightly modified [semver](https://semver.org/). 44 | For version x.y.z: 45 | 46 | * x stays 0 for pre-release (again, until the APRSv1 spec is implemented). 47 | * y updates for any breaking changes (and there may be breaking changes during 48 | pre-release development). 49 | * z updates with new non-breaking functionality, bug fixes, or other changes. 50 | 51 | This is essentially semver shifted to the right. 52 | 53 | ## Contributions 54 | 55 | Please see the [code of conduct](CODE_OF_CONDUCT.md) and 56 | [contributing document](CONTRIBUTING.MD) for details on contributing to 57 | this project. 58 | 59 | ## Development 60 | 61 | ### Dependencies 62 | 63 | The target is .NET 8.0 for development. 64 | 65 | ### Building 66 | 67 | Warnings are treated as errors in the release build (as in CI builds) but are 68 | left as warnings in local development. To verify your code is warning-free, you 69 | can run `dotnet build -c Release` to get the release build behavior. 70 | 71 | ### Generating / Publishing console application binary file 72 | 73 | To generate the console application binary, go to the APRSsharp folder 74 | (`AprsSharp\src\APRSsharp`) and run the command `dotnet publish -c Release` to generate 75 | the console application. 76 | The resulting binary will be placed in 77 | `bin/Release/net8.0//publish`. 78 | Alternatively, use `dotnet publish -c Release -o ` to specify the 79 | output directory. 80 | 81 | ### Nuget Packages and Release 82 | 83 | All packages are versioned together for now via a field in 84 | `Directory.Build.props`. This means if one package gets a new version, they all 85 | do. While this is not exactly ideal, this is currently used to manage 86 | inter-dependencies during early development. 87 | 88 | Maintainers should ensure the version has been updated before creating a new 89 | GitHub release. Creating a release will publish nuget packages to Nuget.org 90 | list. 91 | 92 | ### Running the project/application binary file 93 | 94 | To run the generated console application binary, go to the APRSsharp folder 95 | (`src\AprsSharp\src\APRSsharp`). 96 | 97 | Run the command `dotnet run` which will run AprsSharp with default parameters. 98 | You can run the console app with command line arguments. 99 | Examples of flags used with the arguments. 100 | 101 | Callsign argument as {`dotnet run -- --callsign` with --callsign/-c/--cgn}. 102 | Password as {`dotnet run -- --password` with --password/-p/--pwd/--pass}. 103 | Server name as {`dotnet run -- --server` with --server or -s or --svr}. 104 | Filter arguments as {`dotnet run -- --filter` and --filter or -f}. 105 | 106 | For example `dotnet run -- -c "arg" --pwd "args" --server "args" -f "args"`. 107 | 108 | You can specify different combinations of the commandline flags. 109 | Either use 0, 1, 2, 3 or all the flags combination of flags. 110 | 111 | For missing flags, the console application will use the default arguments. 112 | 113 | To gracefully terminate the application, press the command `Q` or `q`. 114 | This will exit the application after the last packet is received. 115 | 116 | To force terminate the application, press the command `Ctrl + c` 117 | 118 | #### Serial Permissions 119 | 120 | Serial port permissions are often protected. 121 | In Linux, communicating with a serial port may require one of the following steps: 122 | 123 | 1. Adding your user to the appropriate group. Read 124 | [this StackExchange](https://unix.stackexchange.com/questions/14354/read-write-to-a-serial-port-without-root) 125 | thread for details. 126 | 2. Running as root or using `sudo`. 127 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, religion, or sexual identity 11 | and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the 27 | overall community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or 32 | advances of any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email 36 | address, without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to Cameron Bielstein. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html](https://www.contributor-covenant.org/version/2/0/code_of_conduct.html). 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). 128 | Translations are available at 129 | [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). 130 | -------------------------------------------------------------------------------- /src/AprsParser/InfoField/MessageInfo.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.AprsParser 2 | { 3 | using System; 4 | using System.Text; 5 | using System.Text.RegularExpressions; 6 | using AprsSharp.AprsParser.Extensions; 7 | 8 | /// 9 | /// Represents an info field for packet type . 10 | /// 11 | public class MessageInfo : InfoField 12 | { 13 | /// 14 | /// The list of characters which may not be used in the message content. 15 | /// 16 | private static readonly char[] ContentDisallowedChars = { '|', '~', '{' }; 17 | 18 | /// 19 | /// Initializes a new instance of the class. 20 | /// 21 | /// A string encoding of a . 22 | public MessageInfo(string encodedInfoField) 23 | : base(encodedInfoField) 24 | { 25 | if (string.IsNullOrWhiteSpace(encodedInfoField)) 26 | { 27 | throw new ArgumentNullException(nameof(encodedInfoField)); 28 | } 29 | 30 | if (Type != PacketType.Message) 31 | { 32 | throw new ArgumentException($"Packet encoding not of type {nameof(PacketType.Message)}. Type was {Type}", nameof(encodedInfoField)); 33 | } 34 | 35 | Match match = Regex.Match(encodedInfoField, RegexStrings.MessageWithId); 36 | if (match.Success) 37 | { 38 | match.AssertSuccess(PacketType.Message, nameof(encodedInfoField)); 39 | 40 | Addressee = match.Groups[1].Value.TrimEnd(); 41 | 42 | if (match.Groups[2].Success) 43 | { 44 | Content = match.Groups[2].Value; 45 | } 46 | 47 | if (match.Groups[4].Success) 48 | { 49 | Id = match.Groups[4].Value; 50 | } 51 | } 52 | else 53 | { 54 | throw new ArgumentException("Did not match RegexStrings.Message"); 55 | } 56 | } 57 | 58 | /// 59 | /// Initializes a new instance of the class. 60 | /// 61 | /// The station to which this message is addressed. 62 | /// The content of the message, optional. 63 | /// An ID for the message, optional. If supplied, this requests acknowledgement. 64 | public MessageInfo(string addressee, string? content, string? messageId) 65 | : base(PacketType.Message) 66 | { 67 | if (string.IsNullOrEmpty(addressee)) 68 | { 69 | throw new ArgumentNullException(nameof(addressee)); 70 | } 71 | else if (addressee.Length > 9) 72 | { 73 | throw new ArgumentException("Addressee string may have maximum length 9", nameof(addressee)); 74 | } 75 | 76 | if (messageId != null) 77 | { 78 | if (messageId.Length == 0 || messageId.Length > 5) 79 | { 80 | throw new ArgumentException("If provided, ID must be of length (0,5]", nameof(messageId)); 81 | } 82 | else if (!Regex.IsMatch(messageId, RegexStrings.Alphanumeric)) 83 | { 84 | throw new ArgumentException("If provided, ID must be only alphanumeric", nameof(messageId)); 85 | } 86 | } 87 | 88 | if (content != null) 89 | { 90 | if (content.Length > 67) 91 | { 92 | throw new ArgumentException("Message content must be 67 characters or less", nameof(content)); 93 | } 94 | else if (content.IndexOfAny(ContentDisallowedChars) != -1) 95 | { 96 | throw new ArgumentException("Message content may not include `|`, `~`, or `{`", nameof(content)); 97 | } 98 | } 99 | 100 | Addressee = addressee; 101 | Content = content; 102 | Id = messageId; 103 | } 104 | 105 | /// 106 | /// Gets the addressee of the message. 107 | /// 108 | public string Addressee { get; } 109 | 110 | /// 111 | /// Gets the message content. 112 | /// 113 | public string? Content { get; } 114 | 115 | /// 116 | /// Gets the message ID, which uniquely identifies a message 117 | /// between a sender and a receiver. 118 | /// 119 | public string? Id { get; } 120 | 121 | /// 122 | public override string Encode() 123 | { 124 | StringBuilder encoded = new StringBuilder(); 125 | 126 | encoded.Append($":{Addressee}"); 127 | 128 | // Encoded addressee must have length 9 and is padded with 129 | // spaces if too short 130 | if (Addressee.Length < 9) 131 | { 132 | var spacesToAdd = 9 - Addressee.Length; 133 | for (var i = 0; i < spacesToAdd; ++i) 134 | { 135 | encoded.Append(' '); 136 | } 137 | } 138 | 139 | encoded.Append(':'); 140 | 141 | encoded.Append(Content ?? string.Empty); 142 | 143 | if (Id != null) 144 | { 145 | encoded.Append('{'); 146 | encoded.Append(Id); 147 | } 148 | 149 | return encoded.ToString(); 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/AprsParser/RegexStrings.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.AprsParser 2 | { 3 | /// 4 | /// Holds strings for regex comparisons for reuse. 5 | /// 6 | internal static class RegexStrings 7 | { 8 | /// 9 | /// Same as but symbols are optional. 10 | /// Three groups: Full, alphanumeric grid, symbols. 11 | /// 12 | public const string MaidenheadGridWithOptionalSymbols = $@"{MaidenheadGridWithSymbols}?"; 13 | 14 | /// 15 | /// Matches Maidenhead grid with symbols. 16 | /// Three groups: Full, alphanumeric grid, symbols. 17 | /// 18 | public const string MaidenheadGridWithSymbols = @"([a-zA-Z0-9]{4,8})(.{2})"; 19 | 20 | /// 21 | /// Matches Maidenhead grid of 4-6 characters without symbols. 22 | /// One group: Full match. 23 | /// 24 | public const string MaidenheadGrid4or6NoSymbols = @"([a-zA-Z0-9]{4,6})"; 25 | 26 | /// 27 | /// Matchdes a lat/long position, including with ambiguity, including symbols. 28 | /// Five matches: 29 | /// Full 30 | /// Latitute 31 | /// Symbol table ID 32 | /// Longitude 33 | /// Symbol Code. 34 | /// 35 | public const string PositionLatLongWithSymbols = @"([0-9 \.NS]{8})(.)([0-9 \.EW]{9})(.)"; 36 | 37 | /// 38 | /// Matchdes a compressed latitude and longitude with optional additional data 39 | /// Six matches: 40 | /// Symbol table ID 41 | /// Compressed Latitude 42 | /// Compressed Longitude 43 | /// Symbol code 44 | /// Compressed one of: course/speed, radio range, or altitude 45 | /// Compressed data type. 46 | /// 47 | public const string CompressedPosition = @"(.)(.{4})(.{4})(.)(.{2})(.)"; 48 | 49 | /// 50 | /// Same as but forces full line match. 51 | /// 52 | /// 53 | public const string MaidenheadGridFullLine = $@"^{MaidenheadGridWithOptionalSymbols}$"; 54 | 55 | /// 56 | /// Matches a Miadenhead Grid in square brackets [] with comment. 57 | /// Four groups: Full, alphanumeric grid, comment. 58 | /// 59 | public const string MaidenheadGridLocatorBeacon = $@"^\[{MaidenheadGrid4or6NoSymbols}\](.+)?$"; 60 | 61 | /// 62 | /// Matches a Status info field with Maidenhead grid and optional comment (comment separated by a space) 63 | /// Four matches: Full, full maidenhead, alphanumeric grid, symbols, comment. 64 | /// 65 | public const string StatusWithMaidenheadAndComment = $@"^>({MaidenheadGridWithSymbols}) ?(.+)?$"; 66 | 67 | /// 68 | /// Matches a Status info field without Maidenhead grid. Allows optional timestamp and comment. 69 | /// Three matches: 70 | /// Full 71 | /// Timestamp 72 | /// Comment. 73 | /// 74 | public const string StatusWithOptionalTimestampAndComment = @"^>([0-9]{6}z)?(([^|~\n])+)?$"; 75 | 76 | /// 77 | /// Matches a PositionWithoutTimestamp info field. 78 | /// Seven mathces: 79 | /// Full 80 | /// Full lat/long coords and symbols 81 | /// Latitude 82 | /// Symbol table 83 | /// Longitude 84 | /// Symbol code 85 | /// Optional comment. 86 | /// 87 | public const string PositionWithoutTimestamp = $@"^[!=]({PositionLatLongWithSymbols})(.+)?$"; 88 | 89 | /// 90 | /// Matches a PositionWithTimestamp info field. 91 | /// 9 mathces: 92 | /// Full 93 | /// Packet type symbol (/ or @) 94 | /// Timestamp 95 | /// Full position 96 | /// Latitude 97 | /// Symbol table 98 | /// Longitude 99 | /// Symbol code 100 | /// Optional comment. 101 | /// 102 | public const string PositionWithTimestamp = $@"^([/@])([0-9]{{6}}[/zh0-9])({PositionLatLongWithSymbols})(.+)?$"; 103 | 104 | /// 105 | /// Matches a full TNC2-encoded packet. 106 | /// 4 matches: 107 | /// Full 108 | /// Sender callsign 109 | /// Path 110 | /// Info field. 111 | /// 112 | public const string Tnc2Packet = @"^([^>]+)>([^:]+):(.+)$"; 113 | 114 | /// 115 | /// Validates that a given string is only alphanumeric characters in a single line. 116 | /// 117 | public const string Alphanumeric = @"^[a-zA-Z0-9]+$"; 118 | 119 | /// 120 | /// Matches a Message info field. 121 | /// N matches: 122 | /// Full 123 | /// Addressee 124 | /// Content (optional) 125 | /// MessageIdTag (includes `{`) 126 | /// MessageId (excludes `{`). 127 | /// 128 | public const string MessageWithId = @"^:(.{9}):([^:~{]+)?({([a-zA-Z0-9]{1,5}))?$"; 129 | 130 | /// 131 | /// Matches a callsign with optional SSID 132 | /// 4 matches: 133 | /// Full 134 | /// Callsign (no SSID) 135 | /// SSID suffix (include dash) (optional) 136 | /// SSID number (optional). 137 | /// 138 | public const string CallsignWithOptionalSsid = @"^([a-zA-Z0-9]{1,6})(-([0-9]{1,2}))?$"; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/AprsParser/InfoField/PositionInfo.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.AprsParser 2 | { 3 | using System; 4 | using System.Text; 5 | using System.Text.RegularExpressions; 6 | using AprsSharp.AprsParser.Extensions; 7 | 8 | /// 9 | /// Represents an info field for the following types of packet: 10 | /// * 11 | /// * 12 | /// * 13 | /// * . 14 | /// 15 | public class PositionInfo : InfoField 16 | { 17 | /// 18 | /// Initializes a new instance of the class. 19 | /// 20 | /// A string encoding of a . 21 | public PositionInfo(string encodedInfoField) 22 | : base(encodedInfoField) 23 | { 24 | if (string.IsNullOrWhiteSpace(encodedInfoField)) 25 | { 26 | throw new ArgumentNullException(nameof(encodedInfoField)); 27 | } 28 | 29 | if (Type == PacketType.PositionWithoutTimestampNoMessaging || Type == PacketType.PositionWithoutTimestampWithMessaging) 30 | { 31 | HasMessaging = Type == PacketType.PositionWithoutTimestampWithMessaging; 32 | 33 | Match match = Regex.Match(encodedInfoField, RegexStrings.PositionWithoutTimestamp); 34 | match.AssertSuccess(PacketType.PositionWithoutTimestampNoMessaging, nameof(encodedInfoField)); 35 | 36 | Position = new Position(match.Groups[1].Value); 37 | 38 | if (match.Groups[6].Success) 39 | { 40 | Comment = match.Groups[6].Value; 41 | } 42 | } 43 | else if (Type == PacketType.PositionWithTimestampNoMessaging || Type == PacketType.PositionWithTimestampWithMessaging) 44 | { 45 | HasMessaging = Type == PacketType.PositionWithTimestampWithMessaging; 46 | 47 | Match match = Regex.Match(encodedInfoField, RegexStrings.PositionWithTimestamp); 48 | match.AssertSuccess( 49 | HasMessaging ? 50 | PacketType.PositionWithTimestampWithMessaging : 51 | PacketType.PositionWithTimestampNoMessaging, 52 | nameof(encodedInfoField)); 53 | 54 | Timestamp = new Timestamp(match.Groups[2].Value); 55 | 56 | Position = new Position(match.Groups[3].Value); 57 | 58 | if (match.Groups[8].Success) 59 | { 60 | Comment = match.Groups[8].Value; 61 | } 62 | } 63 | else 64 | { 65 | throw new ArgumentException($"Packet encoding not one of the position types. Type was {Type}", nameof(encodedInfoField)); 66 | } 67 | } 68 | 69 | /// 70 | /// Initializes a new instance of the class. 71 | /// 72 | /// for this packet. 73 | /// True if the sender supports messaging. 74 | /// Optional for this packet. 75 | /// Optional comment for this packet. 76 | public PositionInfo(Position position, bool hasMessaging, Timestamp? timestamp, string? comment) 77 | { 78 | Position = position; 79 | HasMessaging = hasMessaging; 80 | Timestamp = timestamp; 81 | Comment = comment; 82 | 83 | if (Timestamp == null) 84 | { 85 | Type = HasMessaging ? PacketType.PositionWithoutTimestampWithMessaging 86 | : PacketType.PositionWithoutTimestampNoMessaging; 87 | } 88 | else 89 | { 90 | Type = HasMessaging ? PacketType.PositionWithTimestampWithMessaging 91 | : PacketType.PositionWithTimestampNoMessaging; 92 | } 93 | } 94 | 95 | /// 96 | /// Initializes a new instance of the class. 97 | /// This is the copy constructor. 98 | /// 99 | /// A to copy. 100 | public PositionInfo(PositionInfo positionInfo) 101 | : base(positionInfo) 102 | { 103 | if (positionInfo == null) 104 | { 105 | throw new ArgumentNullException(nameof(positionInfo)); 106 | } 107 | 108 | HasMessaging = positionInfo.HasMessaging; 109 | Comment = positionInfo.Comment; 110 | Timestamp = positionInfo.Timestamp; 111 | Position = positionInfo.Position; 112 | } 113 | 114 | /// 115 | /// Gets a value indicating whether the sender of the packet supports messaging. 116 | /// 117 | public bool HasMessaging { get; } 118 | 119 | /// 120 | /// Gets or sets the packet comment. 121 | /// 122 | public string? Comment { get; protected set; } 123 | 124 | /// 125 | /// Gets the time at which the message was sent. 126 | /// 127 | public Timestamp? Timestamp { get; } 128 | 129 | /// 130 | /// Gets the position from which the message was sent. 131 | /// 132 | public Position Position { get; } 133 | 134 | /// 135 | public override string Encode() => Encode(TimestampType.DHMz); 136 | 137 | /// 138 | /// Encodes an APRS info field to a string. 139 | /// 140 | /// The to use for timestamp encoding. 141 | /// String representation of the info field. 142 | public string Encode(TimestampType timeType = TimestampType.DHMz) 143 | { 144 | if (Position == null) 145 | { 146 | throw new ArgumentException($"Position cannot be null when encoding with type ${Type}"); 147 | } 148 | 149 | StringBuilder encoded = new StringBuilder(); 150 | 151 | encoded.Append(Type.ToChar()); 152 | 153 | if (Type == PacketType.PositionWithTimestampWithMessaging || 154 | Type == PacketType.PositionWithTimestampNoMessaging) 155 | { 156 | if (Timestamp == null) 157 | { 158 | throw new ArgumentException($"Timestamp cannot be null when encoding with type ${Type}"); 159 | } 160 | 161 | encoded.Append(Timestamp.Encode(timeType)); 162 | } 163 | 164 | encoded.Append(Position.Encode()); 165 | encoded.Append(Comment); 166 | 167 | return encoded.ToString(); 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /test/AprsParserUnitTests/InfoField/StatusInfoUnitTests.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharpUnitTests.AprsParser 2 | { 3 | using System; 4 | using System.Linq; 5 | using AprsSharp.AprsParser; 6 | using GeoCoordinatePortable; 7 | using Xunit; 8 | 9 | /// 10 | /// Tests code in the class related to encode/decode of position reports. 11 | /// 12 | public class StatusInfoUnitTests 13 | { 14 | /// 15 | /// Verifies decoding and re-encoding a full status packet in TNC2 format. 16 | /// 17 | [Fact] 18 | public void TestRoundTrip() 19 | { 20 | string encoded = "N0CALL>WIDE2-2:>IO91SX/G My house"; 21 | Packet p = new Packet(encoded); 22 | 23 | Assert.Equal("N0CALL", p.Sender); 24 | Assert.Equal("WIDE2-2", p.Path.Single()); 25 | Assert.True((p.ReceivedTime - DateTime.UtcNow) < TimeSpan.FromMinutes(1)); 26 | Assert.Equal(PacketType.Status, p.InfoField.Type); 27 | 28 | if (p.InfoField is StatusInfo si) 29 | { 30 | // Coordinates not precise when coming from gridhead 31 | Assert.NotNull(si.Position); 32 | double latitude = si.Position.Coordinates.Latitude; 33 | double longitude = si.Position.Coordinates.Longitude; 34 | Assert.Equal(51.98, Math.Round(latitude, 2)); 35 | Assert.Equal(-0.46, Math.Round(longitude, 2)); 36 | Assert.Equal("IO91SX", si.Position.EncodeGridsquare(6, false)); 37 | Assert.Equal('/', si.Position.SymbolTableIdentifier); 38 | Assert.Equal('G', si.Position.SymbolCode); 39 | Assert.Equal("My house", si.Comment); 40 | } 41 | else 42 | { 43 | Assert.IsType(p.InfoField); 44 | } 45 | 46 | Assert.Equal(encoded, p.EncodeTnc2()); 47 | } 48 | 49 | /// 50 | /// Tests decoding a status report with Maidenhead info field based on the APRS spec. 51 | /// 52 | /// Information field for decode. 53 | /// Expected decoded latitute value. 54 | /// Expected decoded longitude value. 55 | /// Expected decoded symbol code value. 56 | /// Expected decoded comment. 57 | [Theory] 58 | [InlineData(">IO91SX/G", 51.98, -0.46, 'G', null)] 59 | [InlineData(">IO91/G", 51.5, -1.0, 'G', null)] 60 | [InlineData(">IO91SX/- My house", 51.98, -0.46, '-', "My house")] 61 | [InlineData(">IO91SX/- ^B7", 51.98, -0.46, '-', "^B7", Skip = "Issue #69: Packet decode does not handle Meteor Scatter beam information")] 62 | public void DecodeStatusReportFormatWithMaidenhead( 63 | string informationField, 64 | double expectedLatitute, 65 | double expectedLongitute, 66 | char expectedSymbolCode, 67 | string? expectedComment) 68 | { 69 | StatusInfo si = new StatusInfo(informationField); 70 | 71 | if (si.Position == null) 72 | { 73 | Assert.NotNull(si.Position); 74 | } 75 | else 76 | { 77 | Assert.Equal(expectedLatitute, Math.Round(si.Position.Coordinates.Latitude, 2)); 78 | Assert.Equal(expectedLongitute, Math.Round(si.Position.Coordinates.Longitude, 2)); 79 | Assert.Equal('/', si.Position.SymbolTableIdentifier); 80 | Assert.Equal(expectedSymbolCode, si.Position.SymbolCode); 81 | } 82 | 83 | Assert.Equal(expectedComment, si.Comment); 84 | } 85 | 86 | /// 87 | /// Tests encoding a status report with Maidenhead info field based on the APRS spec. 88 | /// 89 | /// Packet comment. 90 | /// Position ambiguity. 91 | /// Expected encoding. 92 | [Theory] 93 | [InlineData("", 0, ">IO91SX/G")] // Without comment, no ambiguity 94 | [InlineData("", 2, ">IO91/G")] // Without comment, with ambiguity 95 | [InlineData("My house", 0, ">IO91SX/G My house")] // With comment, without ambiguity 96 | public void EncodeStatusReportFormatWithMaidenhead( 97 | string comment, 98 | int ambiguity, 99 | string expectedEncoding) 100 | { 101 | Position p = new Position(new GeoCoordinate(51.98, -0.46), '/', 'G', ambiguity); 102 | StatusInfo si = new StatusInfo(p, comment); 103 | Assert.Equal(expectedEncoding, si.Encode()); 104 | } 105 | 106 | /// 107 | /// Tests decoding a status report without Maidenhead info field based on the APRS spec. 108 | /// 109 | /// Information field for decode. 110 | /// Expected decoded timestamp. 111 | /// Expected decoded comment. 112 | [Theory] 113 | [InlineData(">Net Control Center", null, "Net Control Center")] 114 | [InlineData(">092345zNet Control Center", "092345z", "Net Control Center")] 115 | public void DecodeStatusReportFormatWithoutMaidenhead( 116 | string informationField, 117 | string? expectedTime, 118 | string? expectedComment) 119 | { 120 | StatusInfo si = new StatusInfo(informationField); 121 | 122 | Assert.Null(si.Position); 123 | Assert.Equal(expectedComment, si.Comment); 124 | 125 | if (expectedTime != null) 126 | { 127 | var expectedTimestamp = new Timestamp(expectedTime); 128 | Assert.NotNull(si.Timestamp); 129 | Assert.Equal(expectedTimestamp.DecodedType, si.Timestamp!.DecodedType); 130 | Assert.Equal(expectedTimestamp.DateTime, si.Timestamp!.DateTime); 131 | } 132 | else 133 | { 134 | Assert.Null(si.Timestamp); 135 | } 136 | } 137 | 138 | /// 139 | /// Tests encoding a status report without Maidenhead info field based on the APRS spec. 140 | /// 141 | /// Time to encode. 142 | /// Comment to encode. 143 | /// Expected encoding. 144 | [Theory] 145 | [InlineData(null, "Net Control Center", ">Net Control Center")] 146 | [InlineData("092345z", "Net Control Center", ">092345zNet Control Center")] 147 | public void EncodeStatusReportFormatWithoutMaidenhead( 148 | string? time, 149 | string comment, 150 | string expectedEncoding) 151 | { 152 | Timestamp? timestamp = time == null ? null : new Timestamp(time); 153 | StatusInfo si = new StatusInfo(timestamp, comment); 154 | Assert.Equal(expectedEncoding, si.Encode()); 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | [Ll]ogs/ 33 | 34 | # Visual Studio Code personal settings files 35 | .vscode 36 | 37 | # Visual Studio 2015/2017 cache/options directory 38 | .vs/ 39 | # Uncomment if you have tasks that create the project's static files in wwwroot 40 | #wwwroot/ 41 | 42 | # Visual Studio 2017 auto generated files 43 | Generated\ Files/ 44 | 45 | # MSTest test Results 46 | [Tt]est[Rr]esult*/ 47 | [Bb]uild[Ll]og.* 48 | 49 | # NUnit 50 | *.VisualState.xml 51 | TestResult.xml 52 | nunit-*.xml 53 | 54 | # Build Results of an ATL Project 55 | [Dd]ebugPS/ 56 | [Rr]eleasePS/ 57 | dlldata.c 58 | 59 | # Benchmark Results 60 | BenchmarkDotNet.Artifacts/ 61 | 62 | # .NET Core 63 | project.lock.json 64 | project.fragment.lock.json 65 | artifacts/ 66 | 67 | # StyleCop 68 | StyleCopReport.xml 69 | 70 | # Files built by Visual Studio 71 | *_i.c 72 | *_p.c 73 | *_h.h 74 | *.ilk 75 | *.meta 76 | *.obj 77 | *.iobj 78 | *.pch 79 | *.pdb 80 | *.ipdb 81 | *.pgc 82 | *.pgd 83 | *.rsp 84 | *.sbr 85 | *.tlb 86 | *.tli 87 | *.tlh 88 | *.tmp 89 | *.tmp_proj 90 | *_wpftmp.csproj 91 | *.log 92 | *.vspscc 93 | *.vssscc 94 | .builds 95 | *.pidb 96 | *.svclog 97 | *.scc 98 | 99 | # Chutzpah Test files 100 | _Chutzpah* 101 | 102 | # Visual C++ cache files 103 | ipch/ 104 | *.aps 105 | *.ncb 106 | *.opendb 107 | *.opensdf 108 | *.sdf 109 | *.cachefile 110 | *.VC.db 111 | *.VC.VC.opendb 112 | 113 | # Visual Studio profiler 114 | *.psess 115 | *.vsp 116 | *.vspx 117 | *.sap 118 | 119 | # Visual Studio Trace Files 120 | *.e2e 121 | 122 | # TFS 2012 Local Workspace 123 | $tf/ 124 | 125 | # Guidance Automation Toolkit 126 | *.gpState 127 | 128 | # ReSharper is a .NET coding add-in 129 | _ReSharper*/ 130 | *.[Rr]e[Ss]harper 131 | *.DotSettings.user 132 | 133 | # TeamCity is a build add-in 134 | _TeamCity* 135 | 136 | # DotCover is a Code Coverage Tool 137 | *.dotCover 138 | 139 | # AxoCover is a Code Coverage Tool 140 | .axoCover/* 141 | !.axoCover/settings.json 142 | 143 | # Visual Studio code coverage results 144 | *.coverage 145 | *.coveragexml 146 | 147 | # NCrunch 148 | _NCrunch_* 149 | .*crunch*.local.xml 150 | nCrunchTemp_* 151 | 152 | # MightyMoose 153 | *.mm.* 154 | AutoTest.Net/ 155 | 156 | # Web workbench (sass) 157 | .sass-cache/ 158 | 159 | # Installshield output folder 160 | [Ee]xpress/ 161 | 162 | # DocProject is a documentation generator add-in 163 | DocProject/buildhelp/ 164 | DocProject/Help/*.HxT 165 | DocProject/Help/*.HxC 166 | DocProject/Help/*.hhc 167 | DocProject/Help/*.hhk 168 | DocProject/Help/*.hhp 169 | DocProject/Help/Html2 170 | DocProject/Help/html 171 | 172 | # Click-Once directory 173 | publish/ 174 | 175 | # Publish Web Output 176 | *.[Pp]ublish.xml 177 | *.azurePubxml 178 | # Note: Comment the next line if you want to checkin your web deploy settings, 179 | # but database connection strings (with potential passwords) will be unencrypted 180 | *.pubxml 181 | *.publishproj 182 | 183 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 184 | # checkin your Azure Web App publish settings, but sensitive information contained 185 | # in these scripts will be unencrypted 186 | PublishScripts/ 187 | 188 | # NuGet Packages 189 | *.nupkg 190 | # NuGet Symbol Packages 191 | *.snupkg 192 | # The packages folder can be ignored because of Package Restore 193 | **/[Pp]ackages/* 194 | # except build/, which is used as an MSBuild target. 195 | !**/[Pp]ackages/build/ 196 | # Uncomment if necessary however generally it will be regenerated when needed 197 | #!**/[Pp]ackages/repositories.config 198 | # NuGet v3's project.json files produces more ignorable files 199 | *.nuget.props 200 | *.nuget.targets 201 | 202 | # Microsoft Azure Build Output 203 | csx/ 204 | *.build.csdef 205 | 206 | # Microsoft Azure Emulator 207 | ecf/ 208 | rcf/ 209 | 210 | # Windows Store app package directories and files 211 | AppPackages/ 212 | BundleArtifacts/ 213 | Package.StoreAssociation.xml 214 | _pkginfo.txt 215 | *.appx 216 | *.appxbundle 217 | *.appxupload 218 | 219 | # Visual Studio cache files 220 | # files ending in .cache can be ignored 221 | *.[Cc]ache 222 | # but keep track of directories ending in .cache 223 | !?*.[Cc]ache/ 224 | 225 | # Others 226 | ClientBin/ 227 | ~$* 228 | *~ 229 | *.dbmdl 230 | *.dbproj.schemaview 231 | *.jfm 232 | *.pfx 233 | *.publishsettings 234 | orleans.codegen.cs 235 | 236 | # Including strong name files can present a security risk 237 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 238 | #*.snk 239 | 240 | # Since there are multiple workflows, uncomment next line to ignore bower_components 241 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 242 | #bower_components/ 243 | 244 | # RIA/Silverlight projects 245 | Generated_Code/ 246 | 247 | # Backup & report files from converting an old project file 248 | # to a newer Visual Studio version. Backup files are not needed, 249 | # because we have git ;-) 250 | _UpgradeReport_Files/ 251 | Backup*/ 252 | UpgradeLog*.XML 253 | UpgradeLog*.htm 254 | ServiceFabricBackup/ 255 | *.rptproj.bak 256 | 257 | # SQL Server files 258 | *.mdf 259 | *.ldf 260 | *.ndf 261 | 262 | # Business Intelligence projects 263 | *.rdl.data 264 | *.bim.layout 265 | *.bim_*.settings 266 | *.rptproj.rsuser 267 | *- [Bb]ackup.rdl 268 | *- [Bb]ackup ([0-9]).rdl 269 | *- [Bb]ackup ([0-9][0-9]).rdl 270 | 271 | # Microsoft Fakes 272 | FakesAssemblies/ 273 | 274 | # GhostDoc plugin setting file 275 | *.GhostDoc.xml 276 | 277 | # Node.js Tools for Visual Studio 278 | .ntvs_analysis.dat 279 | node_modules/ 280 | 281 | # Visual Studio 6 build log 282 | *.plg 283 | 284 | # Visual Studio 6 workspace options file 285 | *.opt 286 | 287 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 288 | *.vbw 289 | 290 | # Visual Studio LightSwitch build output 291 | **/*.HTMLClient/GeneratedArtifacts 292 | **/*.DesktopClient/GeneratedArtifacts 293 | **/*.DesktopClient/ModelManifest.xml 294 | **/*.Server/GeneratedArtifacts 295 | **/*.Server/ModelManifest.xml 296 | _Pvt_Extensions 297 | 298 | # Paket dependency manager 299 | .paket/paket.exe 300 | paket-files/ 301 | 302 | # FAKE - F# Make 303 | .fake/ 304 | 305 | # CodeRush personal settings 306 | .cr/personal 307 | 308 | # Python Tools for Visual Studio (PTVS) 309 | __pycache__/ 310 | *.pyc 311 | 312 | # Cake - Uncomment if you are using it 313 | # tools/** 314 | # !tools/packages.config 315 | 316 | # Tabs Studio 317 | *.tss 318 | 319 | # Telerik's JustMock configuration file 320 | *.jmconfig 321 | 322 | # BizTalk build output 323 | *.btp.cs 324 | *.btm.cs 325 | *.odx.cs 326 | *.xsd.cs 327 | 328 | # OpenCover UI analysis results 329 | OpenCover/ 330 | 331 | # Azure Stream Analytics local run output 332 | ASALocalRun/ 333 | 334 | # MSBuild Binary and Structured Log 335 | *.binlog 336 | 337 | # NVidia Nsight GPU debugger configuration file 338 | *.nvuser 339 | 340 | # MFractors (Xamarin productivity tool) working folder 341 | .mfractor/ 342 | 343 | # Local History for Visual Studio 344 | .localhistory/ 345 | 346 | # BeatPulse healthcheck temp database 347 | healthchecksdb 348 | 349 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 350 | MigrationBackup/ 351 | 352 | # Ionide (cross platform F# VS Code tools) working folder 353 | .ionide/ 354 | -------------------------------------------------------------------------------- /src/AprsParser/InfoField/StatusInfo.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.AprsParser 2 | { 3 | using System; 4 | using System.Text; 5 | using System.Text.RegularExpressions; 6 | using AprsSharp.AprsParser.Extensions; 7 | 8 | /// 9 | /// Represents an info field for packet type . 10 | /// 11 | public class StatusInfo : InfoField 12 | { 13 | /// 14 | /// The list of characters which may not be used in a comment on this type. 15 | /// 16 | private static readonly char[] CommentDisallowedChars = { '|', '~' }; 17 | 18 | /// 19 | /// Initializes a new instance of the class. 20 | /// 21 | /// A string encoding of a . 22 | public StatusInfo(string encodedInfoField) 23 | : base(encodedInfoField) 24 | { 25 | if (string.IsNullOrWhiteSpace(encodedInfoField)) 26 | { 27 | throw new ArgumentNullException(nameof(encodedInfoField)); 28 | } 29 | 30 | if (Type != PacketType.Status) 31 | { 32 | throw new ArgumentException($"Packet encoding not of type {nameof(PacketType.Status)}. Type was {Type}", nameof(encodedInfoField)); 33 | } 34 | 35 | // First look with maidenhead 36 | Match match = Regex.Match(encodedInfoField, RegexStrings.StatusWithMaidenheadAndComment); 37 | if (match.Success && Position.IsValidMaidenhead(match.Groups[1].Value)) 38 | { 39 | match.AssertSuccess(PacketType.Status, nameof(encodedInfoField)); 40 | 41 | Position = new Position(); 42 | Position.DecodeMaidenhead(match.Groups[1].Value); 43 | 44 | if (match.Groups[4].Success) 45 | { 46 | Comment = match.Groups[4].Value; 47 | } 48 | 49 | return; 50 | } 51 | 52 | // Next try without maidenhead 53 | match = Regex.Match(encodedInfoField, RegexStrings.StatusWithOptionalTimestampAndComment); 54 | if (match.Success) 55 | { 56 | match.AssertSuccess(PacketType.Status, nameof(encodedInfoField)); 57 | 58 | if (match.Groups[1].Success) 59 | { 60 | Timestamp = new Timestamp(match.Groups[1].Value); 61 | } 62 | 63 | if (match.Groups[2].Success) 64 | { 65 | Comment = match.Groups[2].Value; 66 | } 67 | 68 | return; 69 | } 70 | 71 | throw new ArgumentException("Packet did not match any implemented versions of status info."); 72 | } 73 | 74 | /// 75 | /// Initializes a new instance of the class. 76 | /// 77 | /// A position, which will be encoded as a maidenhead gridsquare locator. 78 | /// An optional comment. 79 | public StatusInfo(Position position, string? comment) 80 | : this(null, position, comment) 81 | { 82 | } 83 | 84 | /// 85 | /// Initializes a new instance of the class. 86 | /// 87 | /// An optional timestamp. 88 | /// An optional comment. 89 | public StatusInfo(Timestamp? timestamp, string? comment) 90 | : this(timestamp, null, comment) 91 | { 92 | } 93 | 94 | /// 95 | /// Initializes a new instance of the class. 96 | /// Consolidates logic from the other constructors. 97 | /// 98 | /// An optional timestamp. 99 | /// A position, which will be encoded as a maidenhead gridsquare locator. 100 | /// An optional comment. 101 | private StatusInfo(Timestamp? timestamp, Position? position, string? comment) 102 | : base(PacketType.Status) 103 | { 104 | if (position != null && timestamp != null) 105 | { 106 | throw new ArgumentException($"{nameof(timestamp)} may not be specified if a position is given."); 107 | } 108 | 109 | if (comment != null) 110 | { 111 | // Validate lengths 112 | if (position != null && comment.Length > 53) 113 | { 114 | // Delimiting space + 53 characters = 54. 115 | // We will add the space during encode, so only 53 here. 116 | throw new ArgumentException( 117 | $"With a position specified, comment may be at most 53 characters. Given comment had length {comment.Length}", 118 | nameof(comment)); 119 | } 120 | else if (timestamp != null && comment.Length > 55) 121 | { 122 | throw new ArgumentException( 123 | $"With a timestamp, comment may be at most 55 characters. Given comment had length {comment.Length}", 124 | nameof(comment)); 125 | } 126 | else if (comment.Length > 62) 127 | { 128 | throw new ArgumentException( 129 | $"Without timestamp or or position, comment may be at most 62 characters. Given comment had length {comment.Length}", 130 | nameof(comment)); 131 | } 132 | 133 | // TODO Issue #90: Share this logic across all packet types with a comment. 134 | // Validate no disallowed characters were used 135 | if (comment.IndexOfAny(CommentDisallowedChars) != -1) 136 | { 137 | throw new ArgumentException($"Comment may not include `|` or `~` but was given: {comment}", nameof(comment)); 138 | } 139 | } 140 | 141 | Timestamp = timestamp; 142 | Position = position; 143 | Comment = comment; 144 | } 145 | 146 | /// 147 | /// Gets the packet comment. 148 | /// 149 | public string? Comment { get; } 150 | 151 | /// 152 | /// Gets the time at which the message was sent. 153 | /// 154 | public Timestamp? Timestamp { get; } 155 | 156 | /// 157 | /// Gets the position from which the message was sent. 158 | /// 159 | public Position? Position { get; } 160 | 161 | /// 162 | public override string Encode() 163 | { 164 | StringBuilder encoded = new StringBuilder(); 165 | 166 | encoded.Append(Type.ToChar()); 167 | 168 | if (Position != null) 169 | { 170 | if (Timestamp != null) 171 | { 172 | throw new ArgumentException($"{nameof(Timestamp)} may not be specified if a position is given."); 173 | } 174 | 175 | encoded.Append(Position.EncodeGridsquare(6, true)); 176 | 177 | if (!string.IsNullOrEmpty(Comment)) 178 | { 179 | encoded.Append($" {Comment}"); 180 | } 181 | } 182 | else 183 | { 184 | if (Timestamp != null) 185 | { 186 | encoded.Append(Timestamp.Encode(TimestampType.DHMz)); 187 | } 188 | 189 | if (!string.IsNullOrEmpty(Comment)) 190 | { 191 | encoded.Append(Comment); 192 | } 193 | } 194 | 195 | return encoded.ToString(); 196 | } 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/AprsParser/Packet.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.AprsParser 2 | { 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Globalization; 6 | using System.Linq; 7 | using System.Text; 8 | using System.Text.RegularExpressions; 9 | using AprsSharp.AprsParser.Extensions; 10 | 11 | /// 12 | /// Represents a full APRS packet. 13 | /// 14 | public class Packet 15 | { 16 | /// 17 | /// Initializes a new instance of the class. 18 | /// 19 | /// The encoded APRS packet. 20 | public Packet(byte[] encodedPacket) 21 | { 22 | if (encodedPacket == null || encodedPacket.Length == 0) 23 | { 24 | throw new ArgumentNullException(nameof(encodedPacket)); 25 | } 26 | 27 | ReceivedTime = DateTime.UtcNow; 28 | 29 | // Attempt to decode TNC2 format 30 | Match match = Regex.Match(Encoding.ASCII.GetString(encodedPacket), RegexStrings.Tnc2Packet); 31 | if (match.Success) 32 | { 33 | match.AssertSuccess("Full TNC2 Packet", nameof(encodedPacket)); 34 | Sender = match.Groups[1].Value; 35 | Path = match.Groups[2].Value.Split(','); 36 | InfoField = InfoField.FromString(match.Groups[3].Value); 37 | return; 38 | } 39 | 40 | // Next attempt to decode AX.25 format 41 | Destination = GetCallsignFromAx25(encodedPacket, 0, out _); 42 | Sender = GetCallsignFromAx25(encodedPacket, 1, out bool isFinalAddress); 43 | Path = new List(); 44 | 45 | for (var i = 2; !isFinalAddress && i < 10; ++i) 46 | { 47 | var pathEntry = GetCallsignFromAx25(encodedPacket, i, out isFinalAddress); 48 | Path.Add(pathEntry); 49 | } 50 | 51 | var infoBytes = encodedPacket.Skip(((Path.Count + 2) * 7) + 2); 52 | InfoField = InfoField.FromString(Encoding.ASCII.GetString(infoBytes.ToArray())); 53 | } 54 | 55 | /// 56 | /// Initializes a new instance of the class. 57 | /// 58 | /// A string representation of an encoded packet. 59 | public Packet(string encodedPacket) 60 | : this(Encoding.ASCII.GetBytes(encodedPacket)) 61 | { 62 | } 63 | 64 | /// 65 | /// Initializes a new instance of the class. 66 | /// 67 | /// The callsign of the sender. 68 | /// The digipath for the packet. 69 | /// An or extending class to send. 70 | public Packet(string sender, IList path, InfoField infoField) 71 | : this(sender, null, path, infoField) 72 | { 73 | } 74 | 75 | /// 76 | /// Initializes a new instance of the class. 77 | /// 78 | /// The callsign of the sender. 79 | /// The callsign of the destination. 80 | /// The digipath for the packet. 81 | /// An or extending class to send. 82 | public Packet(string sender, string? destination, IList path, InfoField infoField) 83 | { 84 | Sender = sender; 85 | Destination = destination; 86 | Path = path; 87 | InfoField = infoField; 88 | ReceivedTime = null; 89 | } 90 | 91 | /// 92 | /// Special values used in AX.25 encoding. 93 | /// 94 | private enum Ax25Control : byte 95 | { 96 | /// 97 | /// Flag value used to mark start and end of frames. 98 | /// 99 | FLAG = 0x7e, 100 | 101 | /// 102 | /// Control field value to denote UI-frame. 103 | /// 104 | UI_FRAME = 0x03, 105 | 106 | /// 107 | /// Protocol ID value to specify no layer three. 108 | /// 109 | NO_LAYER_THREE_PROTOCOL = 0xf0, 110 | } 111 | 112 | /// 113 | /// Gets the sender's callsign. 114 | /// 115 | public string Sender { get; } 116 | 117 | /// 118 | /// Gets the APRS digipath of the packet. 119 | /// 120 | public IList Path { get; } 121 | 122 | /// 123 | /// Gets the destination callsign. 124 | /// 125 | public string? Destination { get; } 126 | 127 | /// 128 | /// Gets the time this packet was decoded. 129 | /// Null if this packet was created instead of decoded. 130 | /// 131 | public DateTime? ReceivedTime { get; } 132 | 133 | /// 134 | /// Gets the APRS information field for this packet. 135 | /// 136 | public InfoField InfoField { get; } 137 | 138 | /// 139 | /// Encodes an APRS packet as a string in TNC2 format. 140 | /// 141 | /// String of packet in TNC2 format. 142 | public string EncodeTnc2() 143 | { 144 | return $"{Sender}>{string.Join(',', Path)}:{InfoField.Encode()}"; 145 | } 146 | 147 | /// 148 | /// Encodes an APRS packet as bytes in AX.25 format. 149 | /// 150 | /// Byte encoding of packet in AX.25 format. 151 | public byte[] EncodeAx25() 152 | { 153 | var encodedInfoField = InfoField.Encode(); 154 | 155 | // Length 156 | // Sender address (7) + Destination address (7) 157 | // + Path (7*N) 158 | // + Control Field (1) + Protocol ID (1) 159 | // + Info field (N) 160 | var numBytes = 16 + (Path.Count * 7) + encodedInfoField.Length; 161 | var encodedBytes = new byte[numBytes]; 162 | 163 | var offset = 0; 164 | 165 | EncodeCallsignBytes(Destination).CopyTo(encodedBytes, offset); 166 | offset += 7; 167 | 168 | EncodeCallsignBytes(Sender, Path.Count == 0).CopyTo(encodedBytes, offset); 169 | offset += 7; 170 | 171 | if (Path.Count > 8) 172 | { 173 | throw new ArgumentException("Path must not have more than 8 entries"); 174 | } 175 | 176 | for (var i = 0; i < Path.Count; ++i) 177 | { 178 | EncodeCallsignBytes(Path[i], i == (Path.Count - 1)).CopyTo(encodedBytes, offset); 179 | offset += 7; 180 | } 181 | 182 | encodedBytes[offset] = (byte)Ax25Control.UI_FRAME; 183 | offset += 1; 184 | 185 | encodedBytes[offset] = (byte)Ax25Control.NO_LAYER_THREE_PROTOCOL; 186 | offset += 1; 187 | 188 | Encoding.ASCII.GetBytes(encodedInfoField).CopyTo(encodedBytes, offset); 189 | 190 | return encodedBytes; 191 | } 192 | 193 | /// 194 | /// Attempts to get a callsign from an encoded AX.25 packet. 195 | /// 196 | /// The bytes of the encoded packet. 197 | /// The number of the callsign (in order in the packet encoding) to attempt to fetch. 198 | /// True if this is flagged as the final address in the frame. 199 | /// A callsign string. 200 | private static string GetCallsignFromAx25(IEnumerable encodedPacket, int callsignNumber, out bool isFinal) 201 | { 202 | int callsignStart = callsignNumber * 7; 203 | 204 | var raw = encodedPacket.Skip(callsignStart).Take(6); 205 | var shifted = raw.Select(r => (byte)(r >> 1)).ToArray(); 206 | var callsign = Encoding.ASCII.GetString(shifted).Trim(); 207 | 208 | var ssid = encodedPacket.ElementAt(callsignStart + 6); 209 | 210 | isFinal = (ssid & 0b1) == 1; 211 | 212 | ssid >>= 1; 213 | ssid &= 0b1111; 214 | 215 | return ssid == 0x0 ? callsign : $"{callsign}-{ssid}"; 216 | } 217 | 218 | /// 219 | /// Encodes a callsign in to the appropriate bytes for AX.25. 220 | /// This includes: 6 bytes of callsign (padded spaces left) 221 | /// 1 byte of SSID as a byte value (not ASCII numbers). 222 | /// If callsign is null, return all spaces and SSID 0. 223 | /// 224 | /// Callsign to encode. 225 | /// Boolean value indicating if this is the final callsign in the address list. 226 | /// If so, a flag is set according to AX.25 spec. 227 | /// Encoded bytes of callsign. Should always be 7 bytes. 228 | private static byte[] EncodeCallsignBytes(string? callsign, bool isFinal = false) 229 | { 230 | if (callsign == null) 231 | { 232 | return Encoding.ASCII.GetBytes(new string(' ', 6)).Append((byte)0).ToArray(); 233 | } 234 | 235 | var matches = Regex.Match(callsign, RegexStrings.CallsignWithOptionalSsid); 236 | matches.AssertSuccess(nameof(RegexStrings.CallsignWithOptionalSsid), nameof(callsign)); 237 | 238 | var call = matches.Groups[1].Value.PadRight(6, ' '); 239 | int ssid = int.TryParse(matches.Groups[3].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int inputSsid) ? 240 | inputSsid : 241 | 0; 242 | 243 | if (ssid > 15 || ssid < 0) 244 | { 245 | throw new ArgumentException("SSID must be in range [0,15]", nameof(callsign)); 246 | } 247 | 248 | var ssidByte = (byte)(0b1110000 | (ssid & 0b1111)); 249 | ssidByte <<= 1; 250 | 251 | if (isFinal) 252 | { 253 | ssidByte |= 0x1; 254 | } 255 | 256 | var shiftedBytes = Encoding.ASCII.GetBytes(call).Select(raw => (byte)(raw << 1)).ToArray(); 257 | return shiftedBytes.Append(ssidByte).ToArray(); 258 | } 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /AprsSharp.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26124.0 5 | MinimumVisualStudioVersion = 15.0.26124.0 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{1883B9C0-75E5-4875-9D01-041634AF4214}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KissTncUnitTests", "test\KissTncUnitTests\KissTncUnitTests.csproj", "{F4237106-9AED-4035-927E-86FF83692FC2}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AprsParserUnitTests", "test\AprsParserUnitTests\AprsParserUnitTests.csproj", "{99A19ACE-79C0-42F5-9122-54763155B551}" 11 | EndProject 12 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{84C77ADC-F145-4261-B098-AC28A3D7271C}" 13 | EndProject 14 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KissTnc", "src\KissTnc\KissTnc.csproj", "{F39A005D-C103-4B6E-B149-C8BAE5922B7B}" 15 | EndProject 16 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AprsParser", "src\AprsParser\AprsParser.csproj", "{5B90C4BB-C402-4FB7-833A-37097D66DDA2}" 17 | EndProject 18 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "APRSsharp", "src\APRSsharp\APRSsharp.csproj", "{82CC0961-D17F-4D44-BD68-29DDEBB320C3}" 19 | EndProject 20 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AprsIsClient", "src\AprsIsClient\AprsIsClient.csproj", "{1C712E13-FF17-4965-AFF5-336FD5B88DFB}" 21 | EndProject 22 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AprsIsClientUnitTests", "test\AprsIsClientUnitTests\AprsIsClientUnitTests.csproj", "{639E762F-D46A-4A09-ADEA-6CFC6943F46A}" 23 | EndProject 24 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared", "src\Shared\Shared.csproj", "{B96AB971-4FAA-4E09-95EA-61A08B3F71D6}" 25 | EndProject 26 | Global 27 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 28 | Debug|Any CPU = Debug|Any CPU 29 | Debug|x64 = Debug|x64 30 | Debug|x86 = Debug|x86 31 | Release|Any CPU = Release|Any CPU 32 | Release|x64 = Release|x64 33 | Release|x86 = Release|x86 34 | EndGlobalSection 35 | GlobalSection(SolutionProperties) = preSolution 36 | HideSolutionNode = FALSE 37 | EndGlobalSection 38 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 39 | {F4237106-9AED-4035-927E-86FF83692FC2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 40 | {F4237106-9AED-4035-927E-86FF83692FC2}.Debug|Any CPU.Build.0 = Debug|Any CPU 41 | {F4237106-9AED-4035-927E-86FF83692FC2}.Debug|x64.ActiveCfg = Debug|Any CPU 42 | {F4237106-9AED-4035-927E-86FF83692FC2}.Debug|x64.Build.0 = Debug|Any CPU 43 | {F4237106-9AED-4035-927E-86FF83692FC2}.Debug|x86.ActiveCfg = Debug|Any CPU 44 | {F4237106-9AED-4035-927E-86FF83692FC2}.Debug|x86.Build.0 = Debug|Any CPU 45 | {F4237106-9AED-4035-927E-86FF83692FC2}.Release|Any CPU.ActiveCfg = Release|Any CPU 46 | {F4237106-9AED-4035-927E-86FF83692FC2}.Release|Any CPU.Build.0 = Release|Any CPU 47 | {F4237106-9AED-4035-927E-86FF83692FC2}.Release|x64.ActiveCfg = Release|Any CPU 48 | {F4237106-9AED-4035-927E-86FF83692FC2}.Release|x64.Build.0 = Release|Any CPU 49 | {F4237106-9AED-4035-927E-86FF83692FC2}.Release|x86.ActiveCfg = Release|Any CPU 50 | {F4237106-9AED-4035-927E-86FF83692FC2}.Release|x86.Build.0 = Release|Any CPU 51 | {99A19ACE-79C0-42F5-9122-54763155B551}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 52 | {99A19ACE-79C0-42F5-9122-54763155B551}.Debug|Any CPU.Build.0 = Debug|Any CPU 53 | {99A19ACE-79C0-42F5-9122-54763155B551}.Debug|x64.ActiveCfg = Debug|Any CPU 54 | {99A19ACE-79C0-42F5-9122-54763155B551}.Debug|x64.Build.0 = Debug|Any CPU 55 | {99A19ACE-79C0-42F5-9122-54763155B551}.Debug|x86.ActiveCfg = Debug|Any CPU 56 | {99A19ACE-79C0-42F5-9122-54763155B551}.Debug|x86.Build.0 = Debug|Any CPU 57 | {99A19ACE-79C0-42F5-9122-54763155B551}.Release|Any CPU.ActiveCfg = Release|Any CPU 58 | {99A19ACE-79C0-42F5-9122-54763155B551}.Release|Any CPU.Build.0 = Release|Any CPU 59 | {99A19ACE-79C0-42F5-9122-54763155B551}.Release|x64.ActiveCfg = Release|Any CPU 60 | {99A19ACE-79C0-42F5-9122-54763155B551}.Release|x64.Build.0 = Release|Any CPU 61 | {99A19ACE-79C0-42F5-9122-54763155B551}.Release|x86.ActiveCfg = Release|Any CPU 62 | {99A19ACE-79C0-42F5-9122-54763155B551}.Release|x86.Build.0 = Release|Any CPU 63 | {F39A005D-C103-4B6E-B149-C8BAE5922B7B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 64 | {F39A005D-C103-4B6E-B149-C8BAE5922B7B}.Debug|Any CPU.Build.0 = Debug|Any CPU 65 | {F39A005D-C103-4B6E-B149-C8BAE5922B7B}.Debug|x64.ActiveCfg = Debug|Any CPU 66 | {F39A005D-C103-4B6E-B149-C8BAE5922B7B}.Debug|x64.Build.0 = Debug|Any CPU 67 | {F39A005D-C103-4B6E-B149-C8BAE5922B7B}.Debug|x86.ActiveCfg = Debug|Any CPU 68 | {F39A005D-C103-4B6E-B149-C8BAE5922B7B}.Debug|x86.Build.0 = Debug|Any CPU 69 | {F39A005D-C103-4B6E-B149-C8BAE5922B7B}.Release|Any CPU.ActiveCfg = Release|Any CPU 70 | {F39A005D-C103-4B6E-B149-C8BAE5922B7B}.Release|Any CPU.Build.0 = Release|Any CPU 71 | {F39A005D-C103-4B6E-B149-C8BAE5922B7B}.Release|x64.ActiveCfg = Release|Any CPU 72 | {F39A005D-C103-4B6E-B149-C8BAE5922B7B}.Release|x64.Build.0 = Release|Any CPU 73 | {F39A005D-C103-4B6E-B149-C8BAE5922B7B}.Release|x86.ActiveCfg = Release|Any CPU 74 | {F39A005D-C103-4B6E-B149-C8BAE5922B7B}.Release|x86.Build.0 = Release|Any CPU 75 | {5B90C4BB-C402-4FB7-833A-37097D66DDA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 76 | {5B90C4BB-C402-4FB7-833A-37097D66DDA2}.Debug|Any CPU.Build.0 = Debug|Any CPU 77 | {5B90C4BB-C402-4FB7-833A-37097D66DDA2}.Debug|x64.ActiveCfg = Debug|Any CPU 78 | {5B90C4BB-C402-4FB7-833A-37097D66DDA2}.Debug|x64.Build.0 = Debug|Any CPU 79 | {5B90C4BB-C402-4FB7-833A-37097D66DDA2}.Debug|x86.ActiveCfg = Debug|Any CPU 80 | {5B90C4BB-C402-4FB7-833A-37097D66DDA2}.Debug|x86.Build.0 = Debug|Any CPU 81 | {5B90C4BB-C402-4FB7-833A-37097D66DDA2}.Release|Any CPU.ActiveCfg = Release|Any CPU 82 | {5B90C4BB-C402-4FB7-833A-37097D66DDA2}.Release|Any CPU.Build.0 = Release|Any CPU 83 | {5B90C4BB-C402-4FB7-833A-37097D66DDA2}.Release|x64.ActiveCfg = Release|Any CPU 84 | {5B90C4BB-C402-4FB7-833A-37097D66DDA2}.Release|x64.Build.0 = Release|Any CPU 85 | {5B90C4BB-C402-4FB7-833A-37097D66DDA2}.Release|x86.ActiveCfg = Release|Any CPU 86 | {5B90C4BB-C402-4FB7-833A-37097D66DDA2}.Release|x86.Build.0 = Release|Any CPU 87 | {82CC0961-D17F-4D44-BD68-29DDEBB320C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 88 | {82CC0961-D17F-4D44-BD68-29DDEBB320C3}.Debug|Any CPU.Build.0 = Debug|Any CPU 89 | {82CC0961-D17F-4D44-BD68-29DDEBB320C3}.Debug|x64.ActiveCfg = Debug|Any CPU 90 | {82CC0961-D17F-4D44-BD68-29DDEBB320C3}.Debug|x64.Build.0 = Debug|Any CPU 91 | {82CC0961-D17F-4D44-BD68-29DDEBB320C3}.Debug|x86.ActiveCfg = Debug|Any CPU 92 | {82CC0961-D17F-4D44-BD68-29DDEBB320C3}.Debug|x86.Build.0 = Debug|Any CPU 93 | {82CC0961-D17F-4D44-BD68-29DDEBB320C3}.Release|Any CPU.ActiveCfg = Release|Any CPU 94 | {82CC0961-D17F-4D44-BD68-29DDEBB320C3}.Release|Any CPU.Build.0 = Release|Any CPU 95 | {82CC0961-D17F-4D44-BD68-29DDEBB320C3}.Release|x64.ActiveCfg = Release|Any CPU 96 | {82CC0961-D17F-4D44-BD68-29DDEBB320C3}.Release|x64.Build.0 = Release|Any CPU 97 | {82CC0961-D17F-4D44-BD68-29DDEBB320C3}.Release|x86.ActiveCfg = Release|Any CPU 98 | {82CC0961-D17F-4D44-BD68-29DDEBB320C3}.Release|x86.Build.0 = Release|Any CPU 99 | {639E762F-D46A-4A09-ADEA-6CFC6943F46A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 100 | {639E762F-D46A-4A09-ADEA-6CFC6943F46A}.Debug|Any CPU.Build.0 = Debug|Any CPU 101 | {639E762F-D46A-4A09-ADEA-6CFC6943F46A}.Debug|x64.ActiveCfg = Debug|Any CPU 102 | {639E762F-D46A-4A09-ADEA-6CFC6943F46A}.Debug|x64.Build.0 = Debug|Any CPU 103 | {639E762F-D46A-4A09-ADEA-6CFC6943F46A}.Debug|x86.ActiveCfg = Debug|Any CPU 104 | {639E762F-D46A-4A09-ADEA-6CFC6943F46A}.Debug|x86.Build.0 = Debug|Any CPU 105 | {639E762F-D46A-4A09-ADEA-6CFC6943F46A}.Release|Any CPU.ActiveCfg = Release|Any CPU 106 | {639E762F-D46A-4A09-ADEA-6CFC6943F46A}.Release|Any CPU.Build.0 = Release|Any CPU 107 | {639E762F-D46A-4A09-ADEA-6CFC6943F46A}.Release|x64.ActiveCfg = Release|Any CPU 108 | {639E762F-D46A-4A09-ADEA-6CFC6943F46A}.Release|x64.Build.0 = Release|Any CPU 109 | {639E762F-D46A-4A09-ADEA-6CFC6943F46A}.Release|x86.ActiveCfg = Release|Any CPU 110 | {639E762F-D46A-4A09-ADEA-6CFC6943F46A}.Release|x86.Build.0 = Release|Any CPU 111 | {1C712E13-FF17-4965-AFF5-336FD5B88DFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 112 | {1C712E13-FF17-4965-AFF5-336FD5B88DFB}.Debug|Any CPU.Build.0 = Debug|Any CPU 113 | {1C712E13-FF17-4965-AFF5-336FD5B88DFB}.Debug|x64.ActiveCfg = Debug|Any CPU 114 | {1C712E13-FF17-4965-AFF5-336FD5B88DFB}.Debug|x64.Build.0 = Debug|Any CPU 115 | {1C712E13-FF17-4965-AFF5-336FD5B88DFB}.Debug|x86.ActiveCfg = Debug|Any CPU 116 | {1C712E13-FF17-4965-AFF5-336FD5B88DFB}.Debug|x86.Build.0 = Debug|Any CPU 117 | {1C712E13-FF17-4965-AFF5-336FD5B88DFB}.Release|Any CPU.ActiveCfg = Release|Any CPU 118 | {1C712E13-FF17-4965-AFF5-336FD5B88DFB}.Release|Any CPU.Build.0 = Release|Any CPU 119 | {1C712E13-FF17-4965-AFF5-336FD5B88DFB}.Release|x64.ActiveCfg = Release|Any CPU 120 | {1C712E13-FF17-4965-AFF5-336FD5B88DFB}.Release|x64.Build.0 = Release|Any CPU 121 | {1C712E13-FF17-4965-AFF5-336FD5B88DFB}.Release|x86.ActiveCfg = Release|Any CPU 122 | {1C712E13-FF17-4965-AFF5-336FD5B88DFB}.Release|x86.Build.0 = Release|Any CPU 123 | {B96AB971-4FAA-4E09-95EA-61A08B3F71D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 124 | {B96AB971-4FAA-4E09-95EA-61A08B3F71D6}.Debug|Any CPU.Build.0 = Debug|Any CPU 125 | {B96AB971-4FAA-4E09-95EA-61A08B3F71D6}.Debug|x64.ActiveCfg = Debug|Any CPU 126 | {B96AB971-4FAA-4E09-95EA-61A08B3F71D6}.Debug|x64.Build.0 = Debug|Any CPU 127 | {B96AB971-4FAA-4E09-95EA-61A08B3F71D6}.Debug|x86.ActiveCfg = Debug|Any CPU 128 | {B96AB971-4FAA-4E09-95EA-61A08B3F71D6}.Debug|x86.Build.0 = Debug|Any CPU 129 | {B96AB971-4FAA-4E09-95EA-61A08B3F71D6}.Release|Any CPU.ActiveCfg = Release|Any CPU 130 | {B96AB971-4FAA-4E09-95EA-61A08B3F71D6}.Release|Any CPU.Build.0 = Release|Any CPU 131 | {B96AB971-4FAA-4E09-95EA-61A08B3F71D6}.Release|x64.ActiveCfg = Release|Any CPU 132 | {B96AB971-4FAA-4E09-95EA-61A08B3F71D6}.Release|x64.Build.0 = Release|Any CPU 133 | {B96AB971-4FAA-4E09-95EA-61A08B3F71D6}.Release|x86.ActiveCfg = Release|Any CPU 134 | {B96AB971-4FAA-4E09-95EA-61A08B3F71D6}.Release|x86.Build.0 = Release|Any CPU 135 | EndGlobalSection 136 | GlobalSection(NestedProjects) = preSolution 137 | {82CC0961-D17F-4D44-BD68-29DDEBB320C3} = {84C77ADC-F145-4261-B098-AC28A3D7271C} 138 | {F4237106-9AED-4035-927E-86FF83692FC2} = {1883B9C0-75E5-4875-9D01-041634AF4214} 139 | {99A19ACE-79C0-42F5-9122-54763155B551} = {1883B9C0-75E5-4875-9D01-041634AF4214} 140 | {F39A005D-C103-4B6E-B149-C8BAE5922B7B} = {84C77ADC-F145-4261-B098-AC28A3D7271C} 141 | {5B90C4BB-C402-4FB7-833A-37097D66DDA2} = {84C77ADC-F145-4261-B098-AC28A3D7271C} 142 | {639E762F-D46A-4A09-ADEA-6CFC6943F46A} = {1883B9C0-75E5-4875-9D01-041634AF4214} 143 | {1C712E13-FF17-4965-AFF5-336FD5B88DFB} = {84C77ADC-F145-4261-B098-AC28A3D7271C} 144 | {B96AB971-4FAA-4E09-95EA-61A08B3F71D6} = {84C77ADC-F145-4261-B098-AC28A3D7271C} 145 | EndGlobalSection 146 | EndGlobal 147 | -------------------------------------------------------------------------------- /test/AprsParserUnitTests/TimestampUnitTests.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharpUnitTests.AprsParser 2 | { 3 | using System; 4 | using AprsSharp.AprsParser; 5 | using Xunit; 6 | 7 | /// 8 | /// Test Timestamp code. 9 | /// 10 | public class TimestampUnitTests 11 | { 12 | /// 13 | /// Pass a day that is out of the bounds of [1,31] and expect an exception. 14 | /// 15 | [Fact] 16 | public void FindCorrectYearAndMonthDayOutOfRange() 17 | { 18 | Assert.Throws(() => Timestamp.FindCorrectYearAndMonth(32, DateTime.Now, out _, out _)); 19 | } 20 | 21 | /// 22 | /// Tests FindCorrectYearAndMonth. 23 | /// 24 | /// A day to find in the most recent applicable month and year. 25 | /// The year for the "hint" (usually the current date). 26 | /// The month for the "hint" (usually the current date). 27 | /// The day for the "hint" (usually the current date). 28 | /// The expected resultant year. 29 | /// The expected resultant month. 30 | [Theory] 31 | [InlineData(7, 2016, 10, 24, 2016, 10)] // Same Month 32 | [InlineData(25, 2016, 10, 24, 2016, 9)] // Previous Month 33 | [InlineData(31, 2016, 1, 1, 2015, 12)] // Previous Year 34 | [InlineData(30, 2016, 3, 1, 2016, 1)] // Two Months Previous 35 | [InlineData(29, 2015, 3, 1, 2015, 1)] // Not leap year (Feb 29, 2015 does NOT exist) 36 | [InlineData(29, 2016, 3, 1, 2016, 2)] // Leap year (Feb 29, 2016 DOES exist) 37 | public void FindCorrectYearAndMonth( 38 | in int day, 39 | in int hintYear, 40 | in int hintMonth, 41 | in int hintDay, 42 | in int expectedYear, 43 | in int expectedMonth) 44 | { 45 | DateTime hint = new DateTime(hintYear, hintMonth, hintDay); 46 | 47 | Timestamp.FindCorrectYearAndMonth(day, hint, out int year, out int month); 48 | 49 | Assert.Equal(expectedYear, year); 50 | Assert.Equal(expectedMonth, month); 51 | } 52 | 53 | /// 54 | /// Tests example given in the APRS101.pdf document for zulu time. 55 | /// 56 | [Fact] 57 | public void DHMZuluTimeFromSpec() 58 | { 59 | Timestamp ts = new Timestamp("092345z"); 60 | 61 | Assert.Equal(TimestampType.DHMz, ts.DecodedType); 62 | Assert.Equal(9, ts.DateTime.Day); 63 | Assert.Equal(23, ts.DateTime.Hour); 64 | Assert.Equal(45, ts.DateTime.Minute); 65 | Assert.Equal(DateTimeKind.Utc, ts.DateTime.Kind); 66 | } 67 | 68 | /// 69 | /// Tests example given in the APRS101.pdf document for local time. 70 | /// 71 | [Fact] 72 | public void DHMNotZuluTimeFromSpec() 73 | { 74 | Timestamp ts = new Timestamp("092345/"); 75 | 76 | Assert.Equal(TimestampType.DHMl, ts.DecodedType); 77 | Assert.Equal(9, ts.DateTime.Day); 78 | Assert.Equal(23, ts.DateTime.Hour); 79 | Assert.Equal(45, ts.DateTime.Minute); 80 | Assert.Equal(DateTimeKind.Local, ts.DateTime.Kind); 81 | } 82 | 83 | /// 84 | /// Test FindCorrectDayMonthAndYear from an HMS packet. 85 | /// This tests all in UTC as all DateTime differences are relative so in theory local vs UTC should not change behavior here. 86 | /// Hint is 2016/11/1 01:30:00, set offsets appropriately for tests. 87 | /// 88 | /// Offset the packet time from the hint. Positive values move packet before hint, negative after. 89 | /// Expected number of days before the hint (0 for today, 1 for yesterday). 90 | [Theory] 91 | [InlineData(60, 0)] // Same day 92 | [InlineData(120, 1)] // Yesterday (pushes hint to "tomorrow" relative to packet) 93 | [InlineData(3, 0)] // Three minutes ahead (test 5 minute packet time buffer) 94 | public void FindCorrectDayMonthAndYear( 95 | int minutesBetweenPacketAndHint, 96 | int expectedDaysBeforeHint) 97 | { 98 | DateTime hint = new DateTime(2016, 11, 18, 1, 30, 0, DateTimeKind.Utc); 99 | DateTime packet = hint.AddMinutes(-1 * minutesBetweenPacketAndHint); 100 | 101 | Timestamp.FindCorrectDayMonthAndYear( 102 | packet.Hour, 103 | packet.Minute, 104 | packet.Second, 105 | hint, 106 | out int day, 107 | out int month, 108 | out int year); 109 | 110 | Assert.Equal(hint.Day - expectedDaysBeforeHint, day); 111 | Assert.Equal(hint.Month, month); 112 | Assert.Equal(hint.Year, year); 113 | } 114 | 115 | /// 116 | /// Test HMS with example from APRS101.pdf. 117 | /// 118 | [Fact] 119 | public void HMSTestFromSpec() 120 | { 121 | Timestamp ts = new Timestamp("234517h"); 122 | 123 | Assert.Equal(TimestampType.HMS, ts.DecodedType); 124 | Assert.Equal(23, ts.DateTime.Hour); 125 | Assert.Equal(45, ts.DateTime.Minute); 126 | Assert.Equal(17, ts.DateTime.Second); 127 | } 128 | 129 | /// 130 | /// Test FindCorrectYear. 131 | /// 132 | /// Offset the packet time from the hint. Positive values move packet before hint, negative after. 133 | /// Offset the packet month from the hint. Positive values move packet before hint, negative after. 134 | /// Expected number of days before the hint (0 for this year, 1 for last year, etc.). 135 | [Theory] 136 | [InlineData(-3, 0, 0)] // 3 minutes ahead (< 5 minutes) is considered same date 137 | [InlineData(0, 1, 0)] // Same Year, 1 month ago 138 | [InlineData(0, 3, 1)] // Last Year, 3 months ago 139 | public void FindCorrectYear( 140 | int minutesBetweenPacketAndHint, 141 | int monthsBetweenPacketAndHint, 142 | int expectedYearsBeforeHint) 143 | { 144 | DateTime hint = new DateTime(2017, 2, 1, 0, 1, 1, DateTimeKind.Utc); 145 | DateTime packet = hint 146 | .AddMonths(-1 * monthsBetweenPacketAndHint) 147 | .AddMinutes(-1 * minutesBetweenPacketAndHint); 148 | 149 | Timestamp.FindCorrectYear( 150 | packet.Month, 151 | packet.Day, 152 | packet.Hour, 153 | packet.Minute, 154 | hint, 155 | out int year); 156 | 157 | Assert.Equal(hint.Year - expectedYearsBeforeHint, year); 158 | } 159 | 160 | /// 161 | /// Test FindCorrectYear leap year edition. 162 | /// 163 | [Fact] 164 | public void FindCorrectYearInLeapYear() 165 | { 166 | DateTime packet = new DateTime(2015, 2, 28, 0, 1, 1, DateTimeKind.Utc); 167 | DateTime hint = packet.AddMonths(1); 168 | 169 | Timestamp.FindCorrectYear( 170 | packet.Month, 171 | packet.Day + 1, 172 | packet.Hour, 173 | packet.Minute, 174 | hint, 175 | out int year); 176 | 177 | Assert.Equal(2012, year); 178 | } 179 | 180 | /// 181 | /// Test MDHM with the example from the APRS spec. 182 | /// 183 | [Fact] 184 | public void MDHMTestFromSpec() 185 | { 186 | Timestamp ts = new Timestamp("10092345"); 187 | 188 | Assert.Equal(TimestampType.MDHM, ts.DecodedType); 189 | Assert.Equal(10, ts.DateTime.Month); 190 | Assert.Equal(9, ts.DateTime.Day); 191 | Assert.Equal(23, ts.DateTime.Hour); 192 | Assert.Equal(45, ts.DateTime.Minute); 193 | } 194 | 195 | /// 196 | /// Encodes in DHM zulu time with the example from the APRS spec. 197 | /// 198 | [Fact] 199 | public void EncodeDHMZuluTestFromSpec() 200 | { 201 | DateTime dt = new DateTime(2016, 10, 9, 23, 45, 17, DateTimeKind.Utc); 202 | Timestamp ts = new Timestamp(dt); 203 | 204 | Assert.Equal("092345z", ts.Encode(TimestampType.DHMz)); 205 | } 206 | 207 | /// 208 | /// Encodes in DHM local time with the example from the APRS spec. 209 | /// 210 | [Fact] 211 | public void EncodeDHMLocalTestFromSpec() 212 | { 213 | DateTime dt = new DateTime(2016, 10, 9, 23, 45, 17, DateTimeKind.Local); 214 | Timestamp ts = new Timestamp(dt); 215 | 216 | Assert.Equal("092345/", ts.Encode(TimestampType.DHMl)); 217 | } 218 | 219 | /// 220 | /// Encodes in HMS with the example from the APRS spec 221 | /// The relevant conversion to HMS format includes conversion to UTC. 222 | /// The object passed to the 223 | /// constructor should be converted to the local machine's time zone to 224 | /// account for the conversion back. 225 | /// 226 | [Fact] 227 | public void EncodeHMSTestFromSpec() 228 | { 229 | DateTime dt = new DateTime(2016, 10, 9, 23, 45, 17, DateTimeKind.Utc); 230 | Timestamp ts = new Timestamp(dt.ToLocalTime()); 231 | 232 | Assert.Equal("234517h", ts.Encode(TimestampType.HMS)); 233 | } 234 | 235 | /// 236 | /// Encodes in MDHM with the example from the APRS spec 237 | /// The relevant conversion to MDHM format includes conversion to UTC. 238 | /// The object passed to the 239 | /// constructor should be converted to the local machine's time zone to 240 | /// account for the conversion back. 241 | /// 242 | [Fact] 243 | public void EncodeMDHMTestFromSpec() 244 | { 245 | DateTime dt = new DateTime(2016, 10, 9, 23, 45, 17, DateTimeKind.Utc); 246 | Timestamp ts = new Timestamp(dt.ToLocalTime()); 247 | Assert.Equal("10092345", ts.Encode(TimestampType.MDHM)); 248 | } 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /src/AprsIsClient/AprsIsClient.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.AprsIsClient 2 | { 3 | using System; 4 | using System.Text.RegularExpressions; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using AprsSharp.AprsParser; 8 | using AprsSharp.Shared; 9 | 10 | /// 11 | /// Delegate for handling a full string from a TCP client. 12 | /// 13 | /// The TCP message. 14 | public delegate void HandleTcpString(string tcpMessage); 15 | 16 | /// 17 | /// Delegate for handling a decoded APRS packet. 18 | /// 19 | /// Decoded APRS . 20 | public delegate void HandlePacket(Packet packet); 21 | 22 | /// 23 | /// Delegate for handling a state change in an . 24 | /// 25 | /// The new state taken by the . 26 | public delegate void HandleStateChange(ConnectionState state); 27 | 28 | /// 29 | /// Delegate for handling a failed packet decode. 30 | /// 31 | /// The that occurred. 32 | /// The string that failed to decode.. 33 | public delegate void HandleParseExcpetion(Exception ex, string encodedPacket); 34 | 35 | /// 36 | /// This class initiates connections and performs authentication 37 | /// to the APRS internet service for receiving packets. 38 | /// 39 | public sealed class AprsIsClient : IDisposable 40 | { 41 | private static string loginResponseRegex = @"^# logresp (.+) (unverified|verified), server (\S+)"; 42 | private readonly ITcpConnection tcpConnection; 43 | private readonly bool disposeITcpConnection; 44 | private readonly TimeSpan loginPeriod = TimeSpan.FromHours(6); 45 | private bool receiving = true; 46 | private ConnectionState state = ConnectionState.NotConnected; 47 | private Timer? keepAliveTimer; 48 | private bool disposed; 49 | 50 | /// 51 | /// Initializes a new instance of the class. 52 | /// 53 | /// An to use for communication. 54 | /// `true` if the should be disposed by , 55 | /// `false` if you intend to reuse the . 56 | public AprsIsClient(ITcpConnection tcpConnection, bool disposeConnection = true) 57 | { 58 | this.tcpConnection = tcpConnection ?? throw new ArgumentNullException(nameof(tcpConnection)); 59 | disposeITcpConnection = disposeConnection; 60 | } 61 | 62 | /// 63 | /// Initializes a new instance of the class. 64 | /// 65 | public AprsIsClient() 66 | : this(new TcpConnection(), true) 67 | { 68 | } 69 | 70 | /// 71 | /// Event raised when TCP message is returned. 72 | /// 73 | public event HandleTcpString? ReceivedTcpMessage; 74 | 75 | /// 76 | /// Event raised when an APRS packet is received and decoded. 77 | /// 78 | public event HandlePacket? ReceivedPacket; 79 | 80 | /// 81 | /// Event raised when changes. 82 | /// 83 | public event HandleStateChange? ChangedState; 84 | 85 | /// 86 | /// Event raised when an error or exception is encountered during packet parsing. 87 | /// 88 | public event HandleParseExcpetion? DecodeFailed; 89 | 90 | /// 91 | /// Gets the state of this connection. 92 | /// 93 | public ConnectionState State 94 | { 95 | get => state; 96 | private set 97 | { 98 | state = value; 99 | ChangedState?.Invoke(value); 100 | } 101 | } 102 | 103 | /// 104 | /// Gets the name of the currently logged in server. 105 | /// 106 | public string? ConnectedServer { get; private set; } = null; 107 | 108 | /// 109 | /// Method to cancel the receipt of packets. 110 | /// 111 | public void Disconnect() 112 | { 113 | receiving = false; 114 | } 115 | 116 | /// 117 | /// The method to implement the authentication and receipt of APRS packets from APRS IS server. 118 | /// 119 | /// The users callsign string. 120 | /// The users password string. 121 | /// The APRS-IS server to contact. 122 | /// The APRS-IS filter string for server-side filtering. 123 | /// Null sends no filter, which is not recommended for most clients and servers. 124 | /// This parameter shouldn't include the `filter` at the start, just the logic string itself. 125 | /// An async task. 126 | public async Task Receive(string callsign, string password, string server, string? filter) 127 | { 128 | try 129 | { 130 | // Open connection 131 | tcpConnection.Connect(server, 14580); 132 | State = ConnectionState.Connected; 133 | 134 | keepAliveTimer?.Dispose(); 135 | keepAliveTimer = new Timer((object _) => SendLogin(callsign, password, filter), null, loginPeriod, loginPeriod); 136 | 137 | // Receive 138 | await Task.Run(() => 139 | { 140 | while (receiving && tcpConnection.Connected) 141 | { 142 | string? received = tcpConnection.ReceiveString(); 143 | if (!string.IsNullOrEmpty(received)) 144 | { 145 | ReceivedTcpMessage?.Invoke(received); 146 | 147 | if (received.StartsWith('#')) 148 | { 149 | if (received.StartsWith("# logresp")) 150 | { 151 | ConnectedServer = GetConnectedServer(received); 152 | State = ConnectionState.LoggedIn; 153 | } 154 | 155 | if (State != ConnectionState.LoggedIn) 156 | { 157 | SendLogin(callsign, password, filter); 158 | } 159 | } 160 | else if (ReceivedPacket != null || DecodeFailed != null) 161 | { 162 | // Only attempt to decode if the user 163 | // wants results from decode. 164 | try 165 | { 166 | Packet p = new Packet(received); 167 | ReceivedPacket?.Invoke(p); 168 | } 169 | catch (Exception ex) 170 | { 171 | DecodeFailed?.Invoke(ex, received); 172 | } 173 | } 174 | } 175 | } 176 | }); 177 | } 178 | finally 179 | { 180 | keepAliveTimer?.Change(Timeout.Infinite, Timeout.Infinite); 181 | tcpConnection.Disconnect(); 182 | State = ConnectionState.Disconnected; 183 | } 184 | } 185 | 186 | /// 187 | [System.Diagnostics.CodeAnalysis.SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP007", Justification = "Guarding with boolean flag passed to constructor.")] 188 | public void Dispose() 189 | { 190 | if (disposed) 191 | { 192 | return; 193 | } 194 | 195 | disposed = true; 196 | 197 | Disconnect(); 198 | 199 | if (disposeITcpConnection) 200 | { 201 | tcpConnection.Dispose(); 202 | } 203 | 204 | keepAliveTimer?.Change(Timeout.Infinite, Timeout.Infinite); 205 | keepAliveTimer?.Dispose(); 206 | } 207 | 208 | /// 209 | /// Gets the connected server from a login response. Null if server name cannot be parsed. 210 | /// 211 | /// The login response string from the APRS server. 212 | private static string GetConnectedServer(string loginResponse) 213 | { 214 | Match match = Regex.Match(loginResponse, loginResponseRegex); 215 | if (match.Success) 216 | { 217 | return match.Groups[3].Value; 218 | } 219 | else 220 | { 221 | throw new ArgumentException($"Could not parse server login response message: {loginResponse}"); 222 | } 223 | } 224 | 225 | /// 226 | /// Sends a login message to the server. 227 | /// 228 | /// The users callsign string. 229 | /// The users password string. 230 | /// The APRS-IS filter string for server-side filtering. 231 | /// Null sends no filter, which is not recommended for most clients and servers. 232 | /// This parameter shouldn't include the `filter` at the start, just the logic string itself. 233 | private void SendLogin(string callsign, string password, string? filter) 234 | { 235 | string version = Utilities.GetAssemblyVersion(); 236 | var loginMessage = $"user {callsign} pass {password} vers AprsSharp {version}"; 237 | 238 | if (filter != null) 239 | { 240 | loginMessage += $" filter {filter}"; 241 | } 242 | 243 | tcpConnection.SendString(loginMessage); 244 | } 245 | 246 | /// 247 | /// Static class that defines different constants. 248 | /// 249 | public static class AprsIsConstants 250 | { 251 | /// 252 | /// This defines the default callsign. 253 | /// 254 | public const string DefaultCallsign = "N0CALL"; 255 | 256 | /// 257 | /// This defines the default password. 258 | /// 259 | public const string DefaultPassword = "-1"; 260 | 261 | /// 262 | /// This defines the default server to connect to. 263 | /// 264 | public const string DefaultServerName = "rotate.aprs2.net"; 265 | 266 | /// 267 | /// This defines the default filter. 268 | /// 269 | public const string DefaultFilter = "r/50.5039/4.4699/50"; 270 | } 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /test/KissTncUnitTests/BaseTncUnitTests.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharpUnitTests.KissTnc; 2 | 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Text; 6 | using AprsSharp.KissTnc; 7 | using Microsoft.VisualStudio.TestTools.UnitTesting; 8 | 9 | /// 10 | /// Base class for testing implementations. 11 | /// 12 | /// Implementation of for test. 13 | public abstract class BaseTNCUnitTests 14 | where T : Tnc 15 | { 16 | /// 17 | /// Builds and returns a of type T with a mocked 18 | /// underlying connection. 19 | /// 20 | /// A with a mocked connection. 21 | public abstract T BuildTestTnc(); 22 | 23 | /// 24 | /// Test sample taken from https://en.wikipedia.org/wiki/KISS_(TNC)#Packet_format. 25 | /// 26 | [TestMethod] 27 | public void EncodeFrameData1() 28 | { 29 | using T tnc = BuildTestTnc(); 30 | tnc.SetTncPort(0); 31 | 32 | string message = "TEST"; 33 | 34 | byte[] encodedBytes = tnc.SendData(Encoding.ASCII.GetBytes(message)); 35 | 36 | byte[] correctAnswer = new byte[7] { 0xC0, 0x00, 0x54, 0x45, 0x53, 0x54, 0xC0 }; 37 | 38 | Assert.AreEqual(correctAnswer.Length, encodedBytes.Length); 39 | 40 | for (int i = 0; i < correctAnswer.Length; ++i) 41 | { 42 | Assert.AreEqual(correctAnswer[i], encodedBytes[i], "At position " + i); 43 | } 44 | 45 | Assert.IsTrue(true); 46 | } 47 | 48 | /// 49 | /// Test sample taken from https://en.wikipedia.org/wiki/KISS_(TNC)#Packet_format. 50 | /// 51 | [TestMethod] 52 | public void EncodeFrameData2() 53 | { 54 | using T tnc = BuildTestTnc(); 55 | tnc.SetTncPort(5); 56 | 57 | string message = "Hello"; 58 | 59 | byte[] encodedBytes = tnc.SendData(Encoding.ASCII.GetBytes(message)); 60 | 61 | byte[] correctAnswer = new byte[8] { 0xC0, 0x50, 0x48, 0x65, 0x6C, 0x6C, 0x6F, 0xC0 }; 62 | 63 | Assert.AreEqual(correctAnswer.Length, encodedBytes.Length); 64 | 65 | for (int i = 0; i < correctAnswer.Length; ++i) 66 | { 67 | Assert.AreEqual(correctAnswer[i], encodedBytes[i], "At position " + i); 68 | } 69 | } 70 | 71 | /// 72 | /// Test sample taken from https://en.wikipedia.org/wiki/KISS_(TNC)#Packet_format. 73 | /// 74 | [TestMethod] 75 | public void EncodeFrameDataWithEscapes() 76 | { 77 | using T tnc = BuildTestTnc(); 78 | tnc.SetTncPort(0); 79 | 80 | byte[] data = new byte[2] { (byte)SpecialCharacter.FEND, (byte)SpecialCharacter.FESC }; 81 | 82 | byte[] encodedBytes = tnc.SendData(data); 83 | 84 | byte[] correctAnswer = new byte[7] { 0xC0, 0x00, 0xDB, 0xDC, 0xDB, 0xDD, 0xC0 }; 85 | 86 | Assert.AreEqual(correctAnswer.Length, encodedBytes.Length); 87 | 88 | for (int i = 0; i < correctAnswer.Length; ++i) 89 | { 90 | Assert.AreEqual(correctAnswer[i], encodedBytes[i], "At position " + i); 91 | } 92 | } 93 | 94 | /// 95 | /// Test sample taken from https://en.wikipedia.org/wiki/KISS_(TNC)#Packet_format. 96 | /// 97 | [TestMethod] 98 | public void EncodeFrameExitKissMode() 99 | { 100 | using T tnc = BuildTestTnc(); 101 | tnc.SetTncPort(0); 102 | 103 | byte[] encodedBytes = tnc.ExitKISSMode(); 104 | 105 | byte[] correctAnswer = new byte[3] { 0xC0, 0xFF, 0xC0 }; 106 | 107 | Assert.AreEqual(correctAnswer.Length, encodedBytes.Length); 108 | 109 | for (int i = 0; i < correctAnswer.Length; ++i) 110 | { 111 | Assert.AreEqual(correctAnswer[i], encodedBytes[i], "At position " + i); 112 | } 113 | } 114 | 115 | /// 116 | /// Test sample taken from https://en.wikipedia.org/wiki/KISS_(TNC)#Packet_format. 117 | /// 118 | [TestMethod] 119 | public void DataReceivedAtOnce() 120 | { 121 | using T tnc = BuildTestTnc(); 122 | 123 | byte[] receivedData = new byte[7] { 0xC0, 0x00, 0x54, 0x45, 0x53, 0x54, 0xC0 }; 124 | 125 | byte[][] decodedFrames = tnc.DecodeReceivedData(receivedData); 126 | 127 | Assert.AreEqual(1, decodedFrames.Length); 128 | Assert.AreEqual(4, decodedFrames[0].Length); 129 | Assert.AreEqual("TEST", Encoding.ASCII.GetString(decodedFrames[0])); 130 | } 131 | 132 | /// 133 | /// Test sample taken from https://en.wikipedia.org/wiki/KISS_(TNC)#Packet_format. 134 | /// 135 | [TestMethod] 136 | public void DataReceivedSplit() 137 | { 138 | using T tnc = BuildTestTnc(); 139 | 140 | byte[] dataRec1 = new byte[4] { 0xC0, 0x50, 0x48, 0x65 }; 141 | byte[] dataRec2 = new byte[4] { 0x6C, 0x6C, 0x6F, 0xC0 }; 142 | 143 | byte[][] decodedFrames = tnc.DecodeReceivedData(dataRec1); 144 | Assert.AreEqual(0, decodedFrames.Length); 145 | 146 | decodedFrames = tnc.DecodeReceivedData(dataRec2); 147 | Assert.AreEqual(1, decodedFrames.Length); 148 | Assert.AreEqual(5, decodedFrames[0].Length); 149 | Assert.AreEqual("Hello", Encoding.ASCII.GetString(decodedFrames[0])); 150 | } 151 | 152 | /// 153 | /// Test sample taken from https://en.wikipedia.org/wiki/KISS_(TNC)#Packet_format. 154 | /// 155 | [TestMethod] 156 | public void DataReceivedEscapes() 157 | { 158 | using T tnc = BuildTestTnc(); 159 | 160 | byte[] recData = new byte[7] { 0xC0, 0x00, 0xDB, 0xDC, 0xDB, 0xDD, 0xC0 }; 161 | 162 | byte[][] decodedFrames = tnc.DecodeReceivedData(recData); 163 | 164 | Assert.AreEqual(1, decodedFrames.Length); 165 | Assert.AreEqual(2, decodedFrames[0].Length); 166 | Assert.AreEqual((byte)SpecialCharacter.FEND, decodedFrames[0][0]); 167 | Assert.AreEqual((byte)SpecialCharacter.FESC, decodedFrames[0][1]); 168 | } 169 | 170 | /// 171 | /// Test sample taken from https://en.wikipedia.org/wiki/KISS_(TNC)#Packet_format. 172 | /// 173 | [TestMethod] 174 | public void DataReceivedAtOncePrefacedMultipleFEND() 175 | { 176 | using T tnc = BuildTestTnc(); 177 | 178 | byte[] receivedData = new byte[9] { (byte)SpecialCharacter.FEND, (byte)SpecialCharacter.FEND, 0xC0, 0x00, 0x54, 0x45, 0x53, 0x54, 0xC0 }; 179 | 180 | byte[][] decodedFrames = tnc.DecodeReceivedData(receivedData); 181 | 182 | Assert.AreEqual(1, decodedFrames.Length); 183 | Assert.AreEqual(4, decodedFrames[0].Length); 184 | Assert.AreEqual("TEST", Encoding.ASCII.GetString(decodedFrames[0])); 185 | } 186 | 187 | /// 188 | /// Test sample taken from https://en.wikipedia.org/wiki/KISS_(TNC)#Packet_format. 189 | /// 190 | [TestMethod] 191 | public void MultipleFramesDataReceivedAtOnce() 192 | { 193 | using T tnc = BuildTestTnc(); 194 | 195 | byte[] receivedData = new byte[15] { 0xC0, 0x00, 0x54, 0x45, 0x53, 0x54, 0xC0, 0xC0, 0x50, 0x48, 0x65, 0x6C, 0x6C, 0x6F, 0xC0 }; 196 | 197 | byte[][] decodedFrames = tnc.DecodeReceivedData(receivedData); 198 | 199 | Assert.AreEqual(2, decodedFrames.Length); 200 | Assert.AreEqual(4, decodedFrames[0].Length); 201 | Assert.AreEqual("TEST", Encoding.ASCII.GetString(decodedFrames[0])); 202 | Assert.AreEqual(5, decodedFrames[1].Length); 203 | Assert.AreEqual("Hello", Encoding.ASCII.GetString(decodedFrames[1])); 204 | } 205 | 206 | /// 207 | /// Test sample taken from https://en.wikipedia.org/wiki/KISS_(TNC)#Packet_format. 208 | /// 209 | [TestMethod] 210 | public void DelegateReceivedAtOnce() 211 | { 212 | using T tnc = BuildTestTnc(); 213 | 214 | Queue> decodedFrames = new Queue>(); 215 | 216 | tnc.FrameReceivedEvent += (sender, arg) => decodedFrames.Enqueue(arg.Data); 217 | 218 | byte[] receivedData = new byte[7] { 0xC0, 0x00, 0x54, 0x45, 0x53, 0x54, 0xC0 }; 219 | 220 | tnc.DecodeReceivedData(receivedData); 221 | 222 | Assert.AreEqual(1, decodedFrames.Count); 223 | 224 | IReadOnlyList frame = decodedFrames.Dequeue(); 225 | Assert.AreEqual(4, frame.Count); 226 | Assert.AreEqual("TEST", Encoding.ASCII.GetString(frame.ToArray())); 227 | } 228 | 229 | /// 230 | /// Test sample taken from https://en.wikipedia.org/wiki/KISS_(TNC)#Packet_format. 231 | /// 232 | [TestMethod] 233 | public void DelegateDataReceivedSplit() 234 | { 235 | using T tnc = BuildTestTnc(); 236 | 237 | Queue> decodedFrames = new Queue>(); 238 | 239 | tnc.FrameReceivedEvent += (sender, arg) => decodedFrames.Enqueue(arg.Data); 240 | 241 | byte[] dataRec1 = new byte[4] { 0xC0, 0x50, 0x48, 0x65 }; 242 | byte[] dataRec2 = new byte[4] { 0x6C, 0x6C, 0x6F, 0xC0 }; 243 | 244 | tnc.DecodeReceivedData(dataRec1); 245 | 246 | Assert.AreEqual(0, decodedFrames.Count); 247 | 248 | tnc.DecodeReceivedData(dataRec2); 249 | 250 | Assert.AreEqual(1, decodedFrames.Count); 251 | 252 | IReadOnlyList frame = decodedFrames.Dequeue(); 253 | Assert.AreEqual(5, frame.Count); 254 | Assert.AreEqual("Hello", Encoding.ASCII.GetString(frame.ToArray())); 255 | } 256 | 257 | /// 258 | /// Test sample taken from https://en.wikipedia.org/wiki/KISS_(TNC)#Packet_format. 259 | /// 260 | [TestMethod] 261 | public void DelegateDataReceivedEscapes() 262 | { 263 | using T tnc = BuildTestTnc(); 264 | 265 | Queue> decodedFrames = new Queue>(); 266 | 267 | tnc.FrameReceivedEvent += (sender, arg) => decodedFrames.Enqueue(arg.Data); 268 | 269 | byte[] recData = new byte[7] { 0xC0, 0x00, 0xDB, 0xDC, 0xDB, 0xDD, 0xC0 }; 270 | 271 | tnc.DecodeReceivedData(recData); 272 | 273 | Assert.AreEqual(1, decodedFrames.Count); 274 | 275 | IReadOnlyList frame = decodedFrames.Dequeue(); 276 | Assert.AreEqual(2, frame.Count); 277 | Assert.AreEqual((byte)SpecialCharacter.FEND, frame[0]); 278 | Assert.AreEqual((byte)SpecialCharacter.FESC, frame[1]); 279 | } 280 | 281 | /// 282 | /// Test sample taken from https://en.wikipedia.org/wiki/KISS_(TNC)#Packet_format. 283 | /// 284 | [TestMethod] 285 | public void DelegateDataReceivedAtOncePrefacedMultipleFEND() 286 | { 287 | using T tnc = BuildTestTnc(); 288 | 289 | Queue> decodedFrames = new Queue>(); 290 | 291 | tnc.FrameReceivedEvent += (sender, arg) => decodedFrames.Enqueue(arg.Data); 292 | 293 | byte[] receivedData = new byte[9] { (byte)SpecialCharacter.FEND, (byte)SpecialCharacter.FEND, 0xC0, 0x00, 0x54, 0x45, 0x53, 0x54, 0xC0 }; 294 | 295 | tnc.DecodeReceivedData(receivedData); 296 | 297 | Assert.AreEqual(1, decodedFrames.Count); 298 | 299 | IReadOnlyList frame = decodedFrames.Dequeue(); 300 | Assert.AreEqual(4, frame.Count); 301 | Assert.AreEqual("TEST", Encoding.ASCII.GetString(frame.ToArray())); 302 | } 303 | 304 | /// 305 | /// Test sample taken from https://en.wikipedia.org/wiki/KISS_(TNC)#Packet_format. 306 | /// 307 | [TestMethod] 308 | public void DelegateMultipleFramesDataReceivedAtOnce() 309 | { 310 | using T tnc = BuildTestTnc(); 311 | 312 | Queue> decodedFrames = new Queue>(); 313 | 314 | tnc.FrameReceivedEvent += (sender, arg) => decodedFrames.Enqueue(arg.Data); 315 | 316 | byte[] receivedData = new byte[15] { 0xC0, 0x00, 0x54, 0x45, 0x53, 0x54, 0xC0, 0xC0, 0x50, 0x48, 0x65, 0x6C, 0x6C, 0x6F, 0xC0 }; 317 | 318 | tnc.DecodeReceivedData(receivedData); 319 | 320 | Assert.AreEqual(2, decodedFrames.Count); 321 | 322 | IReadOnlyList frame = decodedFrames.Dequeue(); 323 | Assert.AreEqual(4, frame.Count); 324 | Assert.AreEqual("TEST", Encoding.ASCII.GetString(frame.ToArray())); 325 | 326 | frame = decodedFrames.Dequeue(); 327 | Assert.AreEqual(5, frame.Count); 328 | Assert.AreEqual("Hello", Encoding.ASCII.GetString(frame.ToArray())); 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /src/KissTnc/Tnc.cs: -------------------------------------------------------------------------------- 1 | namespace AprsSharp.KissTnc 2 | { 3 | using System; 4 | using System.Collections.Generic; 5 | 6 | /// 7 | /// Abstracts an interface to a TNC using the KISS protocol. 8 | /// 9 | public abstract class Tnc : IDisposable 10 | { 11 | /// 12 | /// A queue of received bytes waiting to be delivered (at the end of a frame). 13 | /// 14 | private readonly Queue receivedBuffer = new Queue(); 15 | 16 | /// 17 | /// Marks if the next character received should be translated as an escaped character. 18 | /// 19 | private bool inEscapeMode = false; 20 | 21 | /// 22 | /// Tracks if the previously received byte was FEND, so we can skip the control byte which comes next. 23 | /// 24 | private bool previousWasFEND = false; 25 | 26 | /// 27 | /// The port on the TNC used for communication. 28 | /// 29 | private byte tncPort; 30 | 31 | /// 32 | /// Track if the object has been disposed or not. 33 | /// 34 | private bool disposed; 35 | 36 | /// 37 | /// Initializes a new instance of the class. 38 | /// 39 | /// The port on the TNC used for communication. 40 | public Tnc(byte port = 0) 41 | { 42 | SetTncPort(port); 43 | } 44 | 45 | /// 46 | /// Delegate function that will be called when a full frame is received and ready to be delivered. 47 | /// 48 | /// The which received the frame. 49 | /// The event argument, which includes the received data. 50 | public delegate void FrameReceivedEventHandler(object sender, FrameReceivedEventArgs a); 51 | 52 | /// 53 | /// The event which will be raised when a full frame is received. 54 | /// 55 | public event FrameReceivedEventHandler? FrameReceivedEvent; 56 | 57 | #pragma warning disable CA1063 58 | /// 59 | public void Dispose() 60 | { 61 | if (disposed) 62 | { 63 | return; 64 | } 65 | 66 | disposed = true; 67 | Dispose(true); 68 | GC.SuppressFinalize(this); 69 | } 70 | #pragma warning restore CA1063 71 | 72 | /// 73 | /// Sets the port on the TNC to usefor transmission. 74 | /// 75 | /// TNC port. 76 | public void SetTncPort(byte port) 77 | { 78 | // ensure this is just the size of a nibble 79 | if (port < 0 || port > 0xF) 80 | { 81 | throw new ArgumentOutOfRangeException(nameof(port), $"Port value must be a nibble in range [0, 0xF], but was instead: {port}"); 82 | } 83 | 84 | tncPort = port; 85 | } 86 | 87 | /// 88 | /// Sends bytes on the SerialPort. 89 | /// 90 | /// Bytes to send. 91 | /// The encoded bytes. 92 | public byte[] SendData(byte[] bytes) 93 | { 94 | if (bytes == null) 95 | { 96 | throw new ArgumentNullException(nameof(bytes)); 97 | } 98 | else if (bytes.Length == 0) 99 | { 100 | throw new ArgumentException("Bytes to send has length zero.", nameof(bytes)); 101 | } 102 | 103 | return EncodeAndSend(Command.DataFrame, bytes); 104 | } 105 | 106 | /// 107 | /// Sets the transmitter keyup delay in 10 ms units. 108 | /// Default is 50, i.e. 500 ms. 109 | /// 110 | /// Delay in 10 ms steps. 111 | /// The encoded bytes. 112 | public byte[] SetTxDelay(byte delay) 113 | { 114 | return EncodeAndSend(Command.TxDelay, new byte[1] { delay }); 115 | } 116 | 117 | /// 118 | /// Sets persistence parameter p in range [0, 255]. 119 | /// Uses formula P = p * 256 - 1. 120 | /// Default is p = 0.25 (such that P = 63). 121 | /// 122 | /// Persistence parameter in range [0, 255]. 123 | /// The encoded bytes. 124 | public byte[] SetPersistenceParameter(byte p) 125 | { 126 | if (p < 0 || p > 255) 127 | { 128 | throw new ArgumentOutOfRangeException(nameof(p), $"p should be in range [0, 255], but was {p}"); 129 | } 130 | 131 | return EncodeAndSend(Command.P, new byte[1] { p }); 132 | } 133 | 134 | /// 135 | /// Sets slot interval in 10 ms units. 136 | /// Default is 10 (i.e., 100ms). 137 | /// 138 | /// Slot interval. 139 | /// The encoded bytes. 140 | public byte[] SetSlotTime(byte slotTime) 141 | { 142 | return EncodeAndSend(Command.SlotTime, new byte[1] { slotTime }); 143 | } 144 | 145 | /// 146 | /// Sets time to hold TX after the frame has been sent, in 10 ms units. 147 | /// Obsolete, but included for backward compatibility. 148 | /// 149 | /// TX tail time. 150 | /// The encoded bytes. 151 | public byte[] SetTxTail(byte time) 152 | { 153 | return EncodeAndSend(Command.TxTail, new byte[1] { time }); 154 | } 155 | 156 | /// 157 | /// Sets duplex state. 158 | /// 159 | /// Half or full duplex. 160 | /// The encoded bytes. 161 | public byte[] SetDuplexMode(DuplexState state) 162 | { 163 | byte commandByte = (state == DuplexState.FullDuplex) ? (byte)1 : (byte)0; 164 | 165 | return EncodeAndSend(Command.FullDuplex, new byte[1] { commandByte }); 166 | } 167 | 168 | /// 169 | /// Sets a hardware specific value. 170 | /// 171 | /// The value to send to the hardware command. 172 | /// The encoded bytes. 173 | public byte[] SetHardwareSpecific(byte value) 174 | { 175 | return EncodeAndSend(Command.SetHardware, new byte[1] { value }); 176 | } 177 | 178 | /// 179 | /// Commands TNC to exit KISS mode. 180 | /// 181 | /// The encoded bytes. 182 | public byte[] ExitKISSMode() 183 | { 184 | return EncodeAndSend(Command.RETURN, Array.Empty()); 185 | } 186 | 187 | /// 188 | /// Adds bytes to the queue, dequeues a complete frame if available. 189 | /// This is public and returns the decoded frames to allow for handling 190 | /// the connection to the TNC separately. However, if you're using a serial 191 | /// TNC connection, it's likely easiest to set handlers and not call this function directly. 192 | /// 193 | /// Bytes which have just arrived. 194 | /// Array of decoded frames as byte arrays. 195 | public byte[][] DecodeReceivedData(byte[] newBytes) 196 | { 197 | Queue receivedFrames = new Queue(); 198 | 199 | if (newBytes == null) 200 | { 201 | throw new ArgumentNullException(nameof(newBytes)); 202 | } 203 | 204 | foreach (byte recByte in newBytes) 205 | { 206 | byte? byteToEnqueue = null; 207 | 208 | if (previousWasFEND) 209 | { 210 | previousWasFEND = recByte == (byte)SpecialCharacter.FEND; 211 | continue; 212 | } 213 | 214 | // Handle escape mode 215 | if (inEscapeMode) 216 | { 217 | switch (recByte) 218 | { 219 | case (byte)SpecialCharacter.TFESC: 220 | byteToEnqueue = (byte)SpecialCharacter.FESC; 221 | break; 222 | 223 | case (byte)SpecialCharacter.TFEND: 224 | byteToEnqueue = (byte)SpecialCharacter.FEND; 225 | break; 226 | 227 | default: 228 | // Not really an escape, push on the previously unused FESC character and move on 229 | receivedBuffer.Enqueue((byte)SpecialCharacter.FESC); 230 | break; 231 | } 232 | 233 | inEscapeMode = false; 234 | } 235 | 236 | // If we have already determined a byte to enqueue, no need to do this step 237 | if (!byteToEnqueue.HasValue) 238 | { 239 | switch (recByte) 240 | { 241 | case (byte)SpecialCharacter.FEND: 242 | byte[] deliverBytes = receivedBuffer.ToArray(); 243 | receivedBuffer.Clear(); 244 | previousWasFEND = true; 245 | 246 | if (deliverBytes.Length > 0) 247 | { 248 | receivedFrames.Enqueue(deliverBytes); 249 | DeliverBytes(deliverBytes); 250 | } 251 | 252 | break; 253 | 254 | case (byte)SpecialCharacter.FESC: 255 | inEscapeMode = true; 256 | break; 257 | 258 | default: 259 | byteToEnqueue = recByte; 260 | break; 261 | } 262 | } 263 | 264 | if (byteToEnqueue.HasValue) 265 | { 266 | receivedBuffer.Enqueue(byteToEnqueue.Value); 267 | } 268 | } 269 | 270 | return receivedFrames.ToArray(); 271 | } 272 | 273 | /// 274 | /// Dipose the class. 275 | /// 276 | /// True to dispose unmanaged resources. 277 | protected virtual void Dispose(bool disposing) 278 | { 279 | } 280 | 281 | /// 282 | /// Send data to the TNC. 283 | /// 284 | /// Bytes to send to the TNC. 285 | protected abstract void SendToTnc(byte[] bytes); 286 | 287 | /// 288 | /// Encodes a frame for KISS protocol. 289 | /// 290 | /// The command to use for the frame. 291 | /// The port to address on the TNC. 292 | /// Optionally, bytes to encode. 293 | /// Encoded bytes. 294 | private static byte[] EncodeFrame(Command command, byte port, byte[] bytes) 295 | { 296 | // We will need at least FEND, command byte, bytes, FEND. Potentially more as bytes could have characters needing escape. 297 | Queue frame = new Queue(3 + ((bytes == null) ? 0 : bytes.Length)); 298 | 299 | frame.Enqueue((byte)SpecialCharacter.FEND); 300 | frame.Enqueue((byte)((port << 4) | (byte)command)); 301 | 302 | foreach (byte b in bytes ?? Array.Empty()) 303 | { 304 | switch (b) 305 | { 306 | case (byte)SpecialCharacter.FEND: 307 | frame.Enqueue((byte)SpecialCharacter.FESC); 308 | frame.Enqueue((byte)SpecialCharacter.TFEND); 309 | break; 310 | 311 | case (byte)SpecialCharacter.FESC: 312 | frame.Enqueue((byte)SpecialCharacter.FESC); 313 | frame.Enqueue((byte)SpecialCharacter.TFESC); 314 | break; 315 | 316 | default: 317 | frame.Enqueue(b); 318 | break; 319 | } 320 | } 321 | 322 | frame.Enqueue((byte)SpecialCharacter.FEND); 323 | 324 | return frame.ToArray(); 325 | } 326 | 327 | /// 328 | /// Encodes and sends a frame of the given type with the given data. 329 | /// 330 | /// Command / frame time to encode. 331 | /// Data to send. 332 | /// The encoded bytes. 333 | private byte[] EncodeAndSend(Command command, byte[] data) 334 | { 335 | byte[] encodedBytes = EncodeFrame(command, tncPort, data); 336 | SendToTnc(encodedBytes); 337 | return encodedBytes; 338 | } 339 | 340 | /// 341 | /// Handles delivery of bytes. Called when the contents of the buffer are ready to be delivered. 342 | /// 343 | /// Bytes to be delivered. 344 | private void DeliverBytes(byte[] bytes) 345 | { 346 | if (bytes == null || bytes.Length == 0) 347 | { 348 | return; 349 | } 350 | 351 | FrameReceivedEvent?.Invoke(this, new FrameReceivedEventArgs(bytes)); 352 | } 353 | } 354 | } 355 | --------------------------------------------------------------------------------