├── 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 |
--------------------------------------------------------------------------------