diff --git a/.gitignore b/.gitignore
index ea8c4bf..51d6621 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,3 @@
/target
+bin
+obj
diff --git a/csharp/DirectIpNet.Native/DirectIpNet.Native.csproj b/csharp/DirectIpNet.Native/DirectIpNet.Native.csproj
new file mode 100644
index 0000000..66195dc
--- /dev/null
+++ b/csharp/DirectIpNet.Native/DirectIpNet.Native.csproj
@@ -0,0 +1,11 @@
+
+
+ net8.0
+ enable
+ Library
+ true
+
+
+
+
+
diff --git a/csharp/DirectIpNet.Native/Exports.cs b/csharp/DirectIpNet.Native/Exports.cs
new file mode 100644
index 0000000..044c6d4
--- /dev/null
+++ b/csharp/DirectIpNet.Native/Exports.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using SBDDirectIP;
+
+namespace SBDDirectIP.Native;
+
+public static unsafe class Exports
+{
+ [StructLayout(LayoutKind.Sequential)]
+ public struct ConfirmationInfo
+ {
+ public uint ClientMsgId;
+ public uint IdReference;
+ public MessageStatus Status;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "directip_send_mt")]
+ public static int SendMt(byte* host, int port, uint clientMsgId, byte* imei, byte* payload, int payloadLen, ConfirmationInfo* confirmation)
+ {
+ try
+ {
+ var hostStr = Marshal.PtrToStringUTF8((IntPtr)host)!;
+ var imeiStr = Marshal.PtrToStringUTF8((IntPtr)imei)!;
+ var data = new byte[payloadLen];
+ if (payload != null && payloadLen > 0)
+ {
+ Marshal.Copy((IntPtr)payload, data, 0, payloadLen);
+ }
+
+ var client = new DirectIpClient(hostStr, port);
+ var msg = MTMessage.Builder()
+ .ClientMsgId(clientMsgId)
+ .Imei(imeiStr)
+ .Payload(data)
+ .Build();
+
+ Task task = client.SendAsync(msg);
+ task.Wait();
+ Confirmation conf = task.Result;
+
+ if (confirmation != null)
+ {
+ confirmation->ClientMsgId = conf.ClientMsgId;
+ confirmation->IdReference = conf.IdReference;
+ confirmation->Status = conf.Status;
+ }
+
+ return 0;
+ }
+ catch
+ {
+ return -1;
+ }
+ }
+}
diff --git a/csharp/DirectIpNet.Native/README.md b/csharp/DirectIpNet.Native/README.md
new file mode 100644
index 0000000..5c450da
--- /dev/null
+++ b/csharp/DirectIpNet.Native/README.md
@@ -0,0 +1,35 @@
+# DirectIpNet.Native
+
+This project exposes the `SBDDirectIP` API as a native C library. It uses
+`UnmanagedCallersOnly` so the resulting binary can be consumed from any language
+that can call C functions.
+
+## Building
+
+Use `dotnet publish` with NativeAOT enabled to produce a shared library:
+
+```shell
+cd csharp/DirectIpNet.Native
+dotnet publish -c Release -r linux-x64
+```
+
+The compiled library will be placed under `bin/Release/net8.0/linux-x64/native`.
+
+## Usage
+
+Include `directip.h` in your C or C++ project and link against the produced
+library. Example:
+
+```c
+#include "directip.h"
+
+int main() {
+ ConfirmationInfo conf;
+ const char payload[] = "Hello";
+ int rc = directip_send_mt("127.0.0.1", 10800, 1,
+ "012345678901234",
+ (const uint8_t*)payload, sizeof(payload)-1,
+ &conf);
+ return rc;
+}
+```
diff --git a/csharp/DirectIpNet.Native/directip.h b/csharp/DirectIpNet.Native/directip.h
new file mode 100644
index 0000000..d85a8df
--- /dev/null
+++ b/csharp/DirectIpNet.Native/directip.h
@@ -0,0 +1,28 @@
+#ifndef DIRECTIP_H
+#define DIRECTIP_H
+
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct {
+ uint32_t ClientMsgId;
+ uint32_t IdReference;
+ int32_t Status;
+} ConfirmationInfo;
+
+int directip_send_mt(const char* host,
+ int port,
+ uint32_t client_msg_id,
+ const char* imei,
+ const uint8_t* payload,
+ int payload_len,
+ ConfirmationInfo* confirmation);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif // DIRECTIP_H
diff --git a/csharp/DirectIpNet/DirectIpClient.cs b/csharp/DirectIpNet/DirectIpClient.cs
new file mode 100644
index 0000000..5441f09
--- /dev/null
+++ b/csharp/DirectIpNet/DirectIpClient.cs
@@ -0,0 +1,29 @@
+using System;
+using System.Net.Sockets;
+using System.Threading.Tasks;
+
+namespace SBDDirectIP;
+
+public class DirectIpClient
+{
+ private readonly string _host;
+ private readonly int _port;
+
+ public DirectIpClient(string host, int port)
+ {
+ _host = host;
+ _port = port;
+ }
+
+ public async Task SendAsync(MTMessage message)
+ {
+ using var client = new TcpClient();
+ await client.ConnectAsync(_host, _port);
+ using var stream = client.GetStream();
+ var data = message.ToArray();
+ await stream.WriteAsync(data, 0, data.Length);
+ var buffer = new byte[56];
+ int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
+ return Confirmation.FromArray(buffer.AsSpan(3));
+ }
+}
diff --git a/csharp/DirectIpNet/MTMessage.cs b/csharp/DirectIpNet/MTMessage.cs
new file mode 100644
index 0000000..95e9b02
--- /dev/null
+++ b/csharp/DirectIpNet/MTMessage.cs
@@ -0,0 +1,241 @@
+using System;
+using System.Buffers.Binary;
+using System.Text;
+
+namespace SBDDirectIP;
+
+public class MTMessage
+{
+ private readonly MTHeader _header;
+ private readonly MTPayload _payload;
+
+ private MTMessage(MTHeader header, MTPayload payload)
+ {
+ _header = header;
+ _payload = payload;
+ }
+
+ public static MTMessageBuilder Builder() => new MTMessageBuilder();
+
+ public byte[] ToArray()
+ {
+ var headerBytes = _header.ToArray();
+ var payloadBytes = _payload.ToArray();
+ var length = (ushort)(headerBytes.Length + payloadBytes.Length);
+
+ var buffer = new byte[3 + length];
+ int offset = 0;
+ buffer[offset++] = 1; // protocol version
+ BinaryPrimitives.WriteUInt16BigEndian(buffer.AsSpan(offset), length);
+ offset += 2;
+ headerBytes.CopyTo(buffer.AsSpan(offset));
+ offset += headerBytes.Length;
+ payloadBytes.CopyTo(buffer.AsSpan(offset));
+ return buffer;
+ }
+
+ public static MTMessage FromArray(ReadOnlySpan data)
+ {
+ if (data.Length < 3) throw new ArgumentException("Invalid message");
+ if (data[0] != 1) throw new ArgumentException("Unsupported protocol version");
+ ushort len = BinaryPrimitives.ReadUInt16BigEndian(data.Slice(1, 2));
+ if (data.Length - 3 < len) throw new ArgumentException("Incomplete message");
+ int offset = 3;
+ // parse header
+ if (data[offset] != 0x41) throw new ArgumentException("Missing header");
+ offset += 1;
+ ushort hLen = BinaryPrimitives.ReadUInt16BigEndian(data.Slice(offset,2));
+ offset += 2;
+ uint clientMsgId = BinaryPrimitives.ReadUInt32BigEndian(data.Slice(offset,4));
+ offset += 4;
+ string imei = System.Text.Encoding.ASCII.GetString(data.Slice(offset,15));
+ offset += 15;
+ ushort flags = BinaryPrimitives.ReadUInt16BigEndian(data.Slice(offset,2));
+ offset += 2;
+ var header = new MTHeader(clientMsgId, imei, flags);
+ // parse payload
+ if (data[offset] != 0x42) throw new ArgumentException("Missing payload");
+ offset += 1;
+ ushort pLen = BinaryPrimitives.ReadUInt16BigEndian(data.Slice(offset,2));
+ offset += 2;
+ var payload = new byte[pLen];
+ data.Slice(offset, pLen).CopyTo(payload);
+ var pl = new MTPayload(payload);
+ return new MTMessage(header, pl);
+ }
+
+ public class MTMessageBuilder
+ {
+ private uint _clientMsgId;
+ private string? _imei;
+ private byte[] _payload = Array.Empty();
+ private ushort _flags = 0;
+
+ public MTMessageBuilder ClientMsgId(uint id) { _clientMsgId = id; return this; }
+ public MTMessageBuilder Imei(string imei) { _imei = imei; return this; }
+ public MTMessageBuilder Payload(byte[] data) { _payload = data ?? Array.Empty(); return this; }
+ public MTMessageBuilder DispositionFlags(ushort flags) { _flags = flags; return this; }
+
+ public MTMessage Build()
+ {
+ if (_imei == null) throw new InvalidOperationException("IMEI missing");
+ var header = new MTHeader(_clientMsgId, _imei, _flags);
+ var payload = new MTPayload(_payload);
+ return new MTMessage(header, payload);
+ }
+ }
+}
+
+///
+/// Status of a confirmation message returned by the gateway.
+/// Mirrors the enum from the original Rust implementation.
+///
+public enum MessageStatus
+{
+ SuccessfulQueueOrder = 0,
+ InvalidImei = -1,
+ UnknownImei = -2,
+ PayloadOversized = -3,
+ PayloadMissing = -4,
+ MtQueueFull = -5,
+ MtResourcesUnavailable = -6,
+ ProtocolViolation = -7,
+ RingAlertsDisabled = -8,
+ SsdNotAttached = -9,
+ SourceAddressRejected = -10,
+ MtmsnOutOfRange = -11,
+ CertificateRejected = -12
+}
+
+internal class MTHeader
+{
+ public uint ClientMsgId { get; }
+ public string Imei { get; }
+ public ushort DispositionFlags { get; }
+
+ public MTHeader(uint clientMsgId, string imei, ushort dispositionFlags)
+ {
+ if (imei.Length != 15) throw new ArgumentException("IMEI must be 15 digits", nameof(imei));
+ ClientMsgId = clientMsgId;
+ Imei = imei;
+ DispositionFlags = dispositionFlags;
+ }
+
+ public byte[] ToArray()
+ {
+ var buffer = new byte[24];
+ int offset = 0;
+ buffer[offset++] = 0x41;
+ BinaryPrimitives.WriteUInt16BigEndian(buffer.AsSpan(offset), 21);
+ offset += 2;
+ BinaryPrimitives.WriteUInt32BigEndian(buffer.AsSpan(offset), ClientMsgId);
+ offset += 4;
+ Encoding.ASCII.GetBytes(Imei).CopyTo(buffer.AsSpan(offset));
+ offset += 15;
+ BinaryPrimitives.WriteUInt16BigEndian(buffer.AsSpan(offset), DispositionFlags);
+ return buffer;
+ }
+}
+
+internal class MTPayload
+{
+ public byte[] Data { get; }
+ public MTPayload(byte[] data)
+ {
+ Data = data ?? Array.Empty();
+ }
+
+ public byte[] ToArray()
+ {
+ var buffer = new byte[3 + Data.Length];
+ int offset = 0;
+ buffer[offset++] = 0x42;
+ BinaryPrimitives.WriteUInt16BigEndian(buffer.AsSpan(offset), (ushort)Data.Length);
+ offset += 2;
+ Data.CopyTo(buffer.AsSpan(offset));
+ return buffer;
+ }
+}
+
+public class Confirmation
+{
+ public uint ClientMsgId { get; }
+ public string Imei { get; }
+ public uint IdReference { get; }
+ public MessageStatus Status { get; }
+
+ public Confirmation(uint clientMsgId, string imei, uint idReference, MessageStatus status)
+ {
+ ClientMsgId = clientMsgId;
+ Imei = imei;
+ IdReference = idReference;
+ Status = status;
+ }
+
+ public static Confirmation FromArray(ReadOnlySpan data)
+ {
+ int offset = 0;
+ if (data[offset++] != 0x44) throw new ArgumentException("Invalid confirmation element");
+ ushort len = BinaryPrimitives.ReadUInt16BigEndian(data.Slice(offset, 2));
+ offset += 2;
+ if (len != 25) throw new ArgumentException("Unexpected confirmation length");
+ uint clientId = BinaryPrimitives.ReadUInt32BigEndian(data.Slice(offset, 4));
+ offset += 4;
+ string imei = Encoding.ASCII.GetString(data.Slice(offset, 15));
+ offset += 15;
+ uint refId = BinaryPrimitives.ReadUInt32BigEndian(data.Slice(offset, 4));
+ offset += 4;
+ short statusCode = BinaryPrimitives.ReadInt16BigEndian(data.Slice(offset, 2));
+ var status = (MessageStatus)statusCode;
+ return new Confirmation(clientId, imei, refId, status);
+ }
+}
+
+public class MOMessage
+{
+ public string Imei { get; }
+ public byte[] Payload { get; }
+
+ private MOMessage(string imei, byte[] payload)
+ {
+ Imei = imei;
+ Payload = payload;
+ }
+
+ public static MOMessage FromArray(ReadOnlySpan data)
+ {
+ if (data.Length < 3)
+ throw new ArgumentException("Invalid data length", nameof(data));
+ if (data[0] != 1)
+ throw new ArgumentException("Unsupported protocol version", nameof(data));
+
+ ushort length = BinaryPrimitives.ReadUInt16BigEndian(data.Slice(1, 2));
+ if (data.Length - 3 < length)
+ throw new ArgumentException("Incomplete message", nameof(data));
+
+ int offset = 3;
+ if (data[offset++] != 0x01)
+ throw new ArgumentException("Missing MO-Header", nameof(data));
+ ushort hlen = BinaryPrimitives.ReadUInt16BigEndian(data.Slice(offset, 2));
+ offset += 2;
+ if (hlen != 28)
+ throw new ArgumentException("Unexpected header length", nameof(data));
+
+ offset += 4; // CDR reference
+ string imei = Encoding.ASCII.GetString(data.Slice(offset, 15));
+ offset += 15;
+ offset += 1; // session status
+ offset += 2; // MOMSN
+ offset += 2; // MTMSN
+ offset += 4; // time of session
+
+ if (data[offset++] != 0x02)
+ throw new ArgumentException("Missing MO-Payload", nameof(data));
+ ushort plen = BinaryPrimitives.ReadUInt16BigEndian(data.Slice(offset, 2));
+ offset += 2;
+ byte[] payload = data.Slice(offset, plen).ToArray();
+
+ return new MOMessage(imei, payload);
+ }
+}
+
diff --git a/csharp/DirectIpNet/README.md b/csharp/DirectIpNet/README.md
new file mode 100644
index 0000000..b396044
--- /dev/null
+++ b/csharp/DirectIpNet/README.md
@@ -0,0 +1,38 @@
+# SBDDirectIP
+
+This is a lightweight C# implementation of Iridium's Short Burst Data Direct-IP protocol.
+It provides the ability to create and send Mobile Terminated (MT) messages,
+parse confirmation responses from the gateway and decode Mobile Originated (MO)
+messages. The code mirrors the functionality of the Rust library contained in
+the repository but targets .NET applications.
+
+## Building
+
+```
+dotnet build
+```
+
+## Usage
+
+```
+var client = new SBDDirectIP.DirectIpClient("127.0.0.1", 10800);
+var msg = MTMessage.Builder()
+ .ClientMsgId(123)
+ .Imei("012345678901234")
+ .Payload(System.Text.Encoding.ASCII.GetBytes("Hello"))
+ .Build();
+var confirmation = await client.SendAsync(msg);
+Console.WriteLine(confirmation.Status);
+```
+
+MO messages received from a device can be parsed using `MOMessage.FromArray` to
+extract the encrypted payload:
+
+```
+var mo = MOMessage.FromArray(data);
+byte[] payload = mo.Payload;
+```
+
+This library can be referenced from any .NET language. For integration with
+native applications, see the `DirectIpNet.Native` project which exports a C API
+around this library.
diff --git a/csharp/DirectIpNet/SBDDirectIP.csproj b/csharp/DirectIpNet/SBDDirectIP.csproj
new file mode 100644
index 0000000..0661bde
--- /dev/null
+++ b/csharp/DirectIpNet/SBDDirectIP.csproj
@@ -0,0 +1,7 @@
+
+
+ net8.0
+ enable
+ SBDDirectIP
+
+
diff --git a/csharp/SBDDirectIP.App/MainForm.Designer.cs b/csharp/SBDDirectIP.App/MainForm.Designer.cs
new file mode 100644
index 0000000..d2d9ae9
--- /dev/null
+++ b/csharp/SBDDirectIP.App/MainForm.Designer.cs
@@ -0,0 +1,155 @@
+using System.Windows.Forms;
+
+namespace SBDDirectIP.App;
+
+partial class MainForm
+{
+ ///
+ /// Required designer variable.
+ ///
+ private System.ComponentModel.IContainer? components = null;
+
+ ///
+ /// Clean up any resources being used.
+ ///
+ /// true if managed resources should be disposed; otherwise, false.
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && (components != null))
+ {
+ components.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+
+ #region Windows Form Designer generated code
+
+ ///
+ /// Required method for Designer support - do not modify
+ /// the contents of this method with the code editor.
+ ///
+ private void InitializeComponent()
+ {
+ this.components = new System.ComponentModel.Container();
+ this.imeiLabel = new Label();
+ this.imeiTextBox = new TextBox();
+ this.keyLabel = new Label();
+ this.keyTextBox = new TextBox();
+ this.serverLabel = new Label();
+ this.serverTextBox = new TextBox();
+ this.payloadLabel = new Label();
+ this.payloadTextBox = new TextBox();
+ this.sendButton = new Button();
+ this.outputTextBox = new TextBox();
+ this.encryptCheckBox = new CheckBox();
+ this.decryptCheckBox = new CheckBox();
+ this.asciiCheckBox = new CheckBox();
+ this.SuspendLayout();
+
+ // imeiLabel
+ this.imeiLabel.AutoSize = true;
+ this.imeiLabel.Location = new System.Drawing.Point(10, 15);
+ this.imeiLabel.Name = "imeiLabel";
+ this.imeiLabel.Size = new System.Drawing.Size(130, 15);
+ this.imeiLabel.Text = "IMEI";
+
+ // imeiTextBox
+ this.imeiTextBox.Location = new System.Drawing.Point(150, 10);
+ this.imeiTextBox.Name = "imeiTextBox";
+ this.imeiTextBox.Size = new System.Drawing.Size(440, 23);
+
+ // keyLabel
+ this.keyLabel.AutoSize = true;
+ this.keyLabel.Location = new System.Drawing.Point(10, 45);
+ this.keyLabel.Name = "keyLabel";
+ this.keyLabel.Size = new System.Drawing.Size(130, 15);
+ this.keyLabel.Text = "AES Key (hex)";
+
+ // keyTextBox
+ this.keyTextBox.Location = new System.Drawing.Point(150, 40);
+ this.keyTextBox.Name = "keyTextBox";
+ this.keyTextBox.Size = new System.Drawing.Size(440, 23);
+
+ // serverLabel
+ this.serverLabel.AutoSize = true;
+ this.serverLabel.Location = new System.Drawing.Point(10, 75);
+ this.serverLabel.Name = "serverLabel";
+ this.serverLabel.Size = new System.Drawing.Size(130, 15);
+ this.serverLabel.Text = "Gateway host:port";
+
+ // serverTextBox
+ this.serverTextBox.Location = new System.Drawing.Point(150, 70);
+ this.serverTextBox.Name = "serverTextBox";
+ this.serverTextBox.Size = new System.Drawing.Size(240, 23);
+ this.serverTextBox.Text = "directip.sbd.iridium.com:10800";
+
+ // payloadLabel
+ this.payloadLabel.AutoSize = true;
+ this.payloadLabel.Location = new System.Drawing.Point(10, 105);
+ this.payloadLabel.Name = "payloadLabel";
+ this.payloadLabel.Size = new System.Drawing.Size(130, 15);
+ this.payloadLabel.Text = "Payload (hex)";
+
+ // payloadTextBox
+ this.payloadTextBox.Location = new System.Drawing.Point(150, 100);
+ this.payloadTextBox.Name = "payloadTextBox";
+ this.payloadTextBox.Size = new System.Drawing.Size(240, 23);
+
+ // sendButton
+ this.sendButton.Location = new System.Drawing.Point(150, 130);
+ this.sendButton.Name = "sendButton";
+ this.sendButton.Size = new System.Drawing.Size(75, 23);
+ this.sendButton.Text = "Send";
+ this.sendButton.UseVisualStyleBackColor = true;
+ this.sendButton.Click += new System.EventHandler(this.OnSend);
+
+ // encryptCheckBox
+ this.encryptCheckBox.Location = new System.Drawing.Point(240, 130);
+ this.encryptCheckBox.Name = "encryptCheckBox";
+ this.encryptCheckBox.Size = new System.Drawing.Size(80, 24);
+ this.encryptCheckBox.Text = "Encrypt";
+ this.encryptCheckBox.Checked = true;
+ this.encryptCheckBox.AutoSize = true;
+
+ // decryptCheckBox
+ this.decryptCheckBox.Location = new System.Drawing.Point(330, 130);
+ this.decryptCheckBox.Name = "decryptCheckBox";
+ this.decryptCheckBox.Size = new System.Drawing.Size(80, 24);
+ this.decryptCheckBox.Text = "Decrypt";
+ this.decryptCheckBox.Checked = true;
+ this.decryptCheckBox.AutoSize = true;
+
+ // asciiCheckBox
+ this.asciiCheckBox.Location = new System.Drawing.Point(420, 130);
+ this.asciiCheckBox.Name = "asciiCheckBox";
+ this.asciiCheckBox.Size = new System.Drawing.Size(85, 24);
+ this.asciiCheckBox.Text = "Send ASCII";
+ this.asciiCheckBox.AutoSize = true;
+
+ // outputTextBox
+ this.outputTextBox.Location = new System.Drawing.Point(10, 160);
+ this.outputTextBox.Multiline = true;
+ this.outputTextBox.Name = "outputTextBox";
+ this.outputTextBox.ReadOnly = true;
+ this.outputTextBox.ScrollBars = ScrollBars.Vertical;
+ this.outputTextBox.Size = new System.Drawing.Size(580, 390);
+
+ // MainForm
+ this.ClientSize = new System.Drawing.Size(620, 600);
+ this.Controls.AddRange(new Control[] { this.imeiLabel, this.imeiTextBox, this.keyLabel, this.keyTextBox, this.serverLabel, this.serverTextBox, this.payloadLabel, this.payloadTextBox, this.sendButton, this.encryptCheckBox, this.decryptCheckBox, this.asciiCheckBox, this.outputTextBox });
+ this.Name = "MainForm";
+ this.Text = "SBD DirectIP Client";
+ this.ResumeLayout(false);
+ this.PerformLayout();
+ }
+
+ #endregion
+
+ private Label imeiLabel;
+ private Label keyLabel;
+ private Label serverLabel;
+ private Label payloadLabel;
+ private CheckBox encryptCheckBox;
+ private CheckBox decryptCheckBox;
+ private CheckBox asciiCheckBox;
+}
diff --git a/csharp/SBDDirectIP.App/MainForm.cs b/csharp/SBDDirectIP.App/MainForm.cs
new file mode 100644
index 0000000..70cc1d2
--- /dev/null
+++ b/csharp/SBDDirectIP.App/MainForm.cs
@@ -0,0 +1,236 @@
+using System;
+using System.Windows.Forms;
+using SBDDirectIP;
+using System.Security.Cryptography;
+
+namespace SBDDirectIP.App;
+
+public partial class MainForm : Form
+{
+ private TextBox imeiTextBox = null!;
+ private TextBox keyTextBox = null!;
+ private TextBox serverTextBox = null!;
+ private TextBox payloadTextBox = null!;
+ private Button sendButton = null!;
+ private TextBox outputTextBox = null!;
+ private CheckBox encryptCheckBox = null!;
+ private CheckBox decryptCheckBox = null!;
+ private CheckBox asciiCheckBox = null!;
+ private System.Threading.CancellationTokenSource? listenerCts;
+
+ public MainForm()
+ {
+ InitializeComponent();
+ Load += (_, _) => StartListener();
+ FormClosing += (_, _) => listenerCts?.Cancel();
+
+ imeiTextBox.Text = Properties.Settings.Default.IMEI;
+ keyTextBox.Text = Properties.Settings.Default.AESKey;
+ serverTextBox.Text = Properties.Settings.Default.ServerHostPort;
+ encryptCheckBox.Checked = Properties.Settings.Default.EncryptOnSend;
+ decryptCheckBox.Checked = Properties.Settings.Default.DecryptOnReceive;
+ asciiCheckBox.Checked = Properties.Settings.Default.SendAsAscii;
+ }
+
+ private async void OnSend(object? sender, EventArgs e)
+ {
+ try
+ {
+ sendButton.Enabled = false;
+ Log("Preparing message");
+ string imei = imeiTextBox.Text.Trim();
+ string keyHex = keyTextBox.Text.Trim();
+ string server = serverTextBox.Text.Trim();
+ string payloadInput = payloadTextBox.Text.Trim();
+ byte[] payload;
+ if (asciiCheckBox.Checked)
+ {
+ payload = System.Text.Encoding.ASCII.GetBytes(payloadInput);
+ if (payload.Length == 0 || payload.Length > 30)
+ {
+ Log("Invalid ASCII payload length");
+ MessageBox.Show("ASCII payload must be 1-30 bytes.");
+ return;
+ }
+ }
+ else
+ {
+ var tokens = payloadInput.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (tokens.Length == 0 || tokens.Length > 30)
+ {
+ Log("Invalid payload length");
+ MessageBox.Show("Payload must be space separated hex bytes (max 30 bytes).");
+ return;
+ }
+ payload = new byte[tokens.Length];
+ for (int i = 0; i < tokens.Length; i++)
+ {
+ if (tokens[i].Length != 2 || !byte.TryParse(tokens[i], System.Globalization.NumberStyles.HexNumber, null, out payload[i]))
+ {
+ Log("Invalid payload format");
+ MessageBox.Show("Payload must be space separated hex bytes like 'AA 55'.");
+ return;
+ }
+ }
+ }
+ byte[] key = Array.Empty();
+ if (encryptCheckBox.Checked)
+ {
+ try { key = Convert.FromHexString(keyHex); } catch { }
+ if (key.Length != 32)
+ {
+ Log("Invalid AES key length");
+ MessageBox.Show("AES Key must be 32 bytes (64 hex characters, no spaces).");
+ return;
+ }
+ Log($"Encrypting {payload.Length} bytes");
+ using var aes = Aes.Create();
+ aes.Key = key;
+ aes.IV = new byte[16];
+ using var encryptor = aes.CreateEncryptor();
+ payload = encryptor.TransformFinalBlock(payload, 0, payload.Length);
+ }
+
+ var settings = Properties.Settings.Default;
+ settings.IMEI = imei;
+ settings.AESKey = keyHex;
+ settings.ServerHostPort = server;
+ settings.EncryptOnSend = encryptCheckBox.Checked;
+ settings.DecryptOnReceive = decryptCheckBox.Checked;
+ settings.SendAsAscii = asciiCheckBox.Checked;
+ settings.Save();
+
+ string host = server; int port = 10800;
+ if (server.Contains(":"))
+ {
+ var parts = server.Split(':',2);
+ host = parts[0];
+ port = int.Parse(parts[1]);
+ }
+
+ Log($"Connecting to {host}:{port}");
+ var client = new DirectIpClient(host, port);
+ var msg = MTMessage.Builder()
+ .ClientMsgId(1)
+ .Imei(imei)
+ .Payload(payload)
+ .Build();
+ Log("Sending message");
+ var conf = await client.SendAsync(msg);
+ Log($"Status: {conf.Status} Ref: {conf.IdReference}");
+ outputTextBox.Text = $"Status: {conf.Status}";
+ }
+ catch (Exception ex)
+ {
+ Log($"Error: {ex.Message}");
+ MessageBox.Show(ex.Message);
+ }
+ finally
+ {
+ sendButton.Enabled = true;
+ }
+ }
+
+ private void Log(string message)
+ {
+ if (InvokeRequired)
+ Invoke(new Action(() => Log(message)));
+ else
+ outputTextBox.AppendText($"[{DateTime.Now:HH:mm:ss}] {message}{Environment.NewLine}");
+ }
+
+ private void StartListener()
+ {
+ listenerCts = new System.Threading.CancellationTokenSource();
+ var ct = listenerCts.Token;
+ System.Threading.Tasks.Task.Run(async () =>
+ {
+ var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Any, 10800);
+ listener.Start();
+ Log("Listening on port 10800");
+ try
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ var client = await listener.AcceptTcpClientAsync(ct);
+ _ = HandleClientAsync(client, ct);
+ }
+ }
+ catch (OperationCanceledException) { }
+ finally
+ {
+ listener.Stop();
+ }
+ }, ct);
+ }
+
+ private async System.Threading.Tasks.Task HandleClientAsync(System.Net.Sockets.TcpClient client, System.Threading.CancellationToken ct)
+ {
+ using (client)
+ {
+ try
+ {
+ var stream = client.GetStream();
+ byte[] prefix = new byte[3];
+ if (await ReadExactAsync(stream, prefix.AsMemory(), ct) != 3) return;
+ ushort len = System.Buffers.Binary.BinaryPrimitives.ReadUInt16BigEndian(prefix.AsSpan(1));
+ byte[] buffer = new byte[3 + len];
+ prefix.CopyTo(buffer, 0);
+ if (await ReadExactAsync(stream, buffer.AsMemory(3, len), ct) != len) return;
+
+ var mo = MOMessage.FromArray(buffer);
+ string keyHex = "";
+ bool decrypt = true;
+ this.Invoke(new Action(() =>
+ {
+ keyHex = keyTextBox.Text.Trim();
+ decrypt = decryptCheckBox.Checked;
+ }));
+
+ byte[] plain = mo.Payload;
+ if (decrypt)
+ {
+ byte[] key = Array.Empty();
+ try { key = Convert.FromHexString(keyHex); } catch { }
+ if (key.Length != 32)
+ {
+ Invoke(new Action(() => Log("Received message but key invalid")));
+ return;
+ }
+ using var aes = Aes.Create();
+ aes.Key = key;
+ aes.IV = new byte[16];
+ using var decryptor = aes.CreateDecryptor();
+ plain = decryptor.TransformFinalBlock(mo.Payload, 0, mo.Payload.Length);
+ }
+ string hex = BitConverter.ToString(plain).Replace("-", " ");
+ string ascii = ToPrintableString(plain);
+ Invoke(new Action(() => Log($"RX from {mo.Imei}: {hex} | {ascii}")));
+ }
+ catch (Exception ex)
+ {
+ Invoke(new Action(() => Log($"Receive error: {ex.Message}")));
+ }
+ }
+ }
+
+ private static string ToPrintableString(ReadOnlySpan data)
+ {
+ var sb = new System.Text.StringBuilder(data.Length);
+ foreach (byte b in data)
+ sb.Append(b >= 32 && b < 127 ? (char)b : '.');
+ return sb.ToString();
+ }
+
+ private static async System.Threading.Tasks.Task ReadExactAsync(System.Net.Sockets.NetworkStream stream, Memory buffer, System.Threading.CancellationToken ct)
+ {
+ int read = 0;
+ while (read < buffer.Length)
+ {
+ int n = await stream.ReadAsync(buffer.Slice(read), ct);
+ if (n == 0) break;
+ read += n;
+ }
+ return read;
+ }
+}
diff --git a/csharp/SBDDirectIP.App/Program.cs b/csharp/SBDDirectIP.App/Program.cs
new file mode 100644
index 0000000..e3ef8dc
--- /dev/null
+++ b/csharp/SBDDirectIP.App/Program.cs
@@ -0,0 +1,15 @@
+using System;
+using System.Windows.Forms;
+
+namespace SBDDirectIP.App
+{
+ internal static class Program
+ {
+ [STAThread]
+ static void Main()
+ {
+ ApplicationConfiguration.Initialize();
+ Application.Run(new MainForm());
+ }
+ }
+}
diff --git a/csharp/SBDDirectIP.App/Properties/ApplicationConfiguration.cs b/csharp/SBDDirectIP.App/Properties/ApplicationConfiguration.cs
new file mode 100644
index 0000000..8d63fd7
--- /dev/null
+++ b/csharp/SBDDirectIP.App/Properties/ApplicationConfiguration.cs
@@ -0,0 +1,14 @@
+using System;
+using System.Windows.Forms;
+
+namespace SBDDirectIP.App;
+
+internal static class ApplicationConfiguration
+{
+ public static void Initialize()
+ {
+ Application.SetHighDpiMode(HighDpiMode.SystemAware);
+ Application.EnableVisualStyles();
+ Application.SetCompatibleTextRenderingDefault(false);
+ }
+}
diff --git a/csharp/SBDDirectIP.App/Properties/Settings.cs b/csharp/SBDDirectIP.App/Properties/Settings.cs
new file mode 100644
index 0000000..e2a46fe
--- /dev/null
+++ b/csharp/SBDDirectIP.App/Properties/Settings.cs
@@ -0,0 +1,58 @@
+using System.Configuration;
+
+namespace SBDDirectIP.App.Properties;
+
+internal sealed partial class Settings : ApplicationSettingsBase
+{
+ private static readonly Settings defaultInstance = (Settings)Synchronized(new Settings());
+
+ public static Settings Default => defaultInstance;
+
+ [UserScopedSetting]
+ [DefaultSettingValue("")]
+ public string IMEI
+ {
+ get => (string)this[nameof(IMEI)];
+ set => this[nameof(IMEI)] = value;
+ }
+
+ [UserScopedSetting]
+ [DefaultSettingValue("")]
+ public string AESKey
+ {
+ get => (string)this[nameof(AESKey)];
+ set => this[nameof(AESKey)] = value;
+ }
+
+ [UserScopedSetting]
+ [DefaultSettingValue("directip.sbd.iridium.com:10800")]
+ public string ServerHostPort
+ {
+ get => (string)this[nameof(ServerHostPort)];
+ set => this[nameof(ServerHostPort)] = value;
+ }
+
+ [UserScopedSetting]
+ [DefaultSettingValue("True")]
+ public bool EncryptOnSend
+ {
+ get => (bool)this[nameof(EncryptOnSend)];
+ set => this[nameof(EncryptOnSend)] = value;
+ }
+
+ [UserScopedSetting]
+ [DefaultSettingValue("True")]
+ public bool DecryptOnReceive
+ {
+ get => (bool)this[nameof(DecryptOnReceive)];
+ set => this[nameof(DecryptOnReceive)] = value;
+ }
+
+ [UserScopedSetting]
+ [DefaultSettingValue("False")]
+ public bool SendAsAscii
+ {
+ get => (bool)this[nameof(SendAsAscii)];
+ set => this[nameof(SendAsAscii)] = value;
+ }
+}
diff --git a/csharp/SBDDirectIP.App/README.md b/csharp/SBDDirectIP.App/README.md
new file mode 100644
index 0000000..95c2b09
--- /dev/null
+++ b/csharp/SBDDirectIP.App/README.md
@@ -0,0 +1,21 @@
+# SBDDirectIP Windows App
+
+This WinForms application demonstrates how to send MT messages using `SBDDirectIP.dll`.
+
+## Building
+
+```
+dotnet build
+```
+
+## Usage
+
+Fill in the IMEI, AES key (64 hex characters), gateway host and port,
+and the payload. By default the payload must be space‑separated hexadecimal
+byte values (for example `AA 55 10 01`) with a maximum of 30 bytes. Enable the
+**Send ASCII** option to send the payload as literal ASCII text instead.
+Use the **Encrypt** and **Decrypt** checkboxes to toggle AES256 processing. The
+state of all checkboxes is stored in the user settings. Press **Send** to
+transmit the message. Incoming MO messages are accepted on port 10800 and
+displayed in the log box in both hex and ASCII forms; payloads are decrypted
+only when the **Decrypt** option is enabled.
diff --git a/csharp/SBDDirectIP.App/SBDDirectIP.App.csproj b/csharp/SBDDirectIP.App/SBDDirectIP.App.csproj
new file mode 100644
index 0000000..7c0138b
--- /dev/null
+++ b/csharp/SBDDirectIP.App/SBDDirectIP.App.csproj
@@ -0,0 +1,15 @@
+
+
+ WinExe
+ net8.0-windows
+ true
+ enable
+ enable
+ SBDDirectIP.App
+ x64
+
+
+
+
+
+