KNXnet/IP Tunnelling

Vollständige Byte für Byte Anleitung: wie eine Verbindung zu einem KNX IP Gateway aufgebaut, Telegramme gesendet und empfangen, und die Verbindung wieder sauber getrennt wird, inklusive C# Referenzcode für jeden Schritt.

Ziel Gateway in dieser Anleitung: 192.168.178.42 · Port 3671 (UDP, KNX Standard) · Beispiel Client IP: 192.168.178.23

00Überblick

KNXnet/IP ist die IP Erweiterung des KNX Bus Standards (EN 50090 / ISO/IEC 14543). Ein KNX IP Gateway übersetzt zwischen dem klassischen Twisted Pair Bus und einem Ethernet/IP Netzwerk. Für den Fernzugriff von einem PC aus gibt es zwei Verfahren:

Jeder KNXnet/IP Frame beginnt mit demselben 6 Byte Header, danach folgt ein service spezifischer Body:

Header HPAI (IP Endpunkt) CRI / Connection Header KNX Adresse Control Felder Nutzdaten

Klicke auf einen Byte Kasten in den folgenden Abständen, jeder zeigt Feldname, Bedeutung und Wert.

01Verbindung aufbauen

Der Client schickt ein CONNECT_REQUEST (Service Type 0x0205) an Port 3671 des Gateways. Darin stecken zwei HPAI-Blöcke (Control- und Data-Endpoint, meist identisch, ein Socket reicht) und die CRI mit dem gewünschten Verbindungstyp TUNNEL_CONNECTION.

CONNECT_REQUEST → Gateway

Client → 192.168.178.42:367126 Byte
Klicke auf ein Byte für Details.

CONNECT_RESPONSE ← Gateway

Antwortet das Gateway mit Status 0x00, ist die Verbindung offen. Die zurückgegebene Channel ID wird ab jetzt bei jeder Nachricht mitgeschickt.

192.168.178.42:3671 → Client20 Byte
Klicke auf ein Byte für Details.
Wichtig: Status Byte ≠ 0x00 bedeutet, das Gateway hat abgelehnt (z. B. keine freien Tunnelling Kanäle mehr — viele Geräte erlauben nur 1-5 gleichzeitige Verbindungen). In dem Fall keine weiteren Telegramme senden, sondern erst erneut CONNECT_REQUEST versuchen.

C# — Verbindung aufbauen

Connect.cs
using var udp = new UdpClient(0);
int localPort = ((IPEndPoint)udp.Client.LocalEndPoint).Port;
string localIp = "192.168.178.23"; // deine lokale IP im selben Subnetz

byte[] hpai = BuildHpai(localIp, localPort);   // 08 01 + IP(4) + Port(2)
byte[] cri  = { 0x04, 0x04, 0x02, 0x00 }; // TUNNEL_CONNECTION, TUNNEL_LINKLAYER
byte[] header = BuildHeader(0x0205, hpai.Length * 2 + cri.Length);

byte[] request = Concat(header, hpai, hpai, cri);
udp.Send(request, request.Length, new IPEndPoint(IPAddress.Parse("192.168.178.42"), 3671));

byte[] response = udp.Receive(ref IPEndPoint.Any); // CONNECT_RESPONSE
byte channelId = response[6];
byte status    = response[7]; // 0x00 = OK

02Telegramm senden

Jedes KNX-Telegramm wird als TUNNELING_REQUEST (0x0420) verpackt. Darin steckt ein cEMI-Frame, das eigentliche KNX-Telegramm mit Quell-/Zieladresse und Nutzdaten. Das Gateway bestätigt jede Anfrage separat mit TUNNELING_ACK.

Beispiel: DPT1 „TRUE" an Gruppenadresse 1/0/0

Client → Gateway · Channel 0x01, Sequenz 0x0021 Byte
Klicke auf ein Byte für Details.

TUNNELING_ACK ← Gateway

Gateway → Client10 Byte
Klicke auf ein Byte für Details.

cEMI-Frame im Detail

Der cEMI-Teil (ab Byte 11 des kompletten Frames) folgt immer demselben Muster: MsgCode · AddInfo · Control1 · Control2 · Quelle(2) · Ziel(2) · NPDU-Länge · TPCI · APCI [+Daten]. Bei DPT1/2/3 steckt der Wert in den unteren 6 Bit des APCI-Bytes (kein eigenes Datenbyte); ab DPT5 hängen die Daten als eigene Bytes dahinter.

C# — Telegramm senden

SendGroupValue.cs
// DPT1 (1 Bit, z.B. Schalten) - Wert steckt im APCI Byte selbst
byte[] cemi = {
    0x11, 0x00, 0xBC, 0xE0,       // L_Data.req, Control1, Control2
    0x00, 0x00,                   // Quelladresse (vom Gateway gesetzt)
    0x08, 0x00,                   // Ziel: Gruppenadresse 1/0/0
    0x01, 0x00,                   // NPDU-Länge, TPCI
    (byte)(0x80 | (value ? 1 : 0)) // APCI GroupValueWrite + 1-Bit-Wert
};

byte[] connHeader = { 0x04, channelId, sequenceCounter, 0x00 };
byte[] header     = BuildHeader(0x0420, connHeader.Length + cemi.Length);
byte[] request    = Concat(header, connHeader, cemi);

udp.Send(request, request.Length, gatewayEndpoint);
// -> auf TUNNELING_ACK warten (Service Type 0x0421), sequenceCounter danach +1

03Datenpunkttypen (DPT)

KNX überträgt nur Rohbytes, der Datenpunkttyp einer Gruppenadresse ist nirgends im Telegramm codiert, du musst ihn selbst kennen (aus dem ETS-Projekt). Hier die gängigsten Typen mit Roh Byte Kodierung:

DPTGrößeKodierungBeispiel BytesErgibt
1.xxxSwitch, 1 Bit 0 Byte extern Wert in Bit 0 des APCI Bytes (eingebettet) 81 TRUE (AN)
2.xxxSwitch Control, 1 Bit gesteuert 0 Byte extern Bit1=Control, Bit0=Value — eingebettet 83 Control=1, Value=1
3.xxxDimmen, 4 Bit gesteuert 0 Byte extern Bit3=Richtung, Bit2-0=Schrittweite — eingebettet 8B Aufwärts, Schritt 3
5.001Prozent, 8 Bit unsigned 1 Byte 0-255 linear (Prozent × 2,55) 80 ≈ 50 %
9.xxxFloat, z. B. Temperatur 2 Byte Vorzeichen(1) + Exponent(4) + Mantisse(11), Wert = 0,01 × M × 2^E 0C 24 21,2 °C
10.001Uhrzeit + Wochentag 3 Byte Byte0: Tag(3Bit)+Stunde(5Bit) · Byte1: Minute · Byte2: Sekunde 4E 1A 00 Do, 14:26:00
11.001Datum 3 Byte Tag(5Bit) · Monat(4Bit) · Jahr(7Bit, 0-89=20xx, 90-99=19xx) 14 07 1A 20.07.2026
16.xxxText, ISO-8859-1 14 Byte ASCII/Latin1, mit 0x00 aufgefüllt 48 69 00… "Hi"
Merke: Der Sign Bit Fehler bei DPT9 ist ein klassischer Stolperstein, das Vorzeichen steht in einem eigenen Bit (Bit 15), nicht im obersten Mantissen-Bit. Wer nur anhand der Mantisse „rät", ob negativ, rechnet manche positiven Werte fälschlich negativ.

C# — DPT9 (Float) encodieren/decodieren

Dpt9.cs
static byte[] EncodeDpt9(double value)
{
    double m = value * 100.0;
    int exponent = 0;
    while (m < -2048.0 || m > 2047.0) { m /= 2.0; exponent++; }

    int mantissa = (int)Math.Round(m);
    int sign = mantissa < 0 ? 1 : 0;
    int raw = (sign << 15) | (exponent << 11) | (mantissa & 0x7FF);
    return new[] { (byte)(raw >> 8), (byte)(raw & 0xFF) };
}

static double DecodeDpt9(byte[] data)
{
    int raw = (data[0] << 8) | data[1];
    bool negative = (raw & 0x8000) != 0;   // eigenes Sign-Bit, nicht raten!
    int exponent = (raw >> 11) & 0x0F;
    int mantissa = raw & 0x7FF;
    if (negative) mantissa -= 2048;
    return 0.01 * mantissa * Math.Pow(2, exponent);
}

04Telegramme empfangen

Sobald verbunden, kann das Gateway jederzeit ein TUNNELING_REQUEST vom Bus an dich schicken, nicht nur als Antwort auf eigene Anfragen. Jedes davon muss sofort mit einem eigenen TUNNELING_ACK quittiert werden (Pflicht laut Spezifikation), sonst wiederholt das Gateway die Zustellung.

Der MessageCode im cEMI-Frame unterscheidet zwei Fälle: 0x29 (L_Data.ind) ist ein echtes Bus-Telegramm von einem anderen Gerät, 0x2E (L_Data.con) ist die Bestätigung eines selbst gesendeten Telegramms.

C# — Dauerhaft lauschen

Listen.cs
while (listening)
{
    byte[] data = udp.Receive(ref remoteEP);
    ushort serviceType = (ushort)((data[2] << 8) | data[3]);

    if (serviceType == 0x0420) // TUNNELING_REQUEST vom Gateway
    {
        byte seqCounter = data[8];
        SendTunnelingAck(seqCounter);   // Pflicht: sofort bestätigen

        byte[] cemi = data[10..];
        if (cemi[0] == 0x29) // L_Data.ind - echtes Bus-Telegramm
            HandleIncomingTelegram(cemi);
    }
}

05Verbindung trennen

Sauberes Trennen mit DISCONNECT_REQUEST (0x0209) gibt den Tunnelling-Kanal am Gateway wieder frei — viele Geräte erlauben nur wenige gleichzeitige Verbindungen, ohne sauberes Trennen bleibt der Kanal bis zum Timeout blockiert.

Client → Gateway16 Byte
Klicke auf ein Byte für Details.
Gateway → Client8 Byte
Klicke auf ein Byte für Details.
Heartbeat: Bleibt die Verbindung länger als ~60 Sekunden offen, verlangt die Spezifikation zusätzlich einen periodischen CONNECTIONSTATE_REQUEST (0x0207) — sonst kann das Gateway serverseitig trennen, ohne dass du es merkst.

06Einfaches Beispiel in C#

Ein minimales Konsolenprogramm, das alle drei Schritte aus dieser Anleitung, Verbinden, Senden, Trennen, einmal komplett durchspielt. Struktur ist vorbereitet, der Code selbst folgt an anderer Stelle.

Program.cs
using System.Net;
using System.Net.Sockets;

class Program
{
    static UdpClient?  udp;
    static IPEndPoint? gatewayEndPoint;
    static string      localIp = "";
    static int         localPort;
    static byte        channelId;

    static void Main()
    {
        Print(ConsoleColor.Cyan, "==========================================================================\n" +
                               "                     KNX IP Gateway - DPT1 Sender\n" +
                               "==========================================================================\n");

        Console.ForegroundColor = ConsoleColor.Yellow;
        Console.Write("IP Adresse des KNX IP Gateways (Enter = 192.168.178.42): ");
        Console.ForegroundColor = ConsoleColor.White;
        string? ipInput = Console.ReadLine();
        string ip = string.IsNullOrWhiteSpace(ipInput) ? "192.168.178.42" : ipInput.Trim();

        string groupAddress;
        while (true)
        {
            Console.ForegroundColor = ConsoleColor.Yellow;
            Console.Write("Gruppenadresse (Enter = 1/0/0): ");
            Console.ForegroundColor = ConsoleColor.White;
            string? input = Console.ReadLine();

            if (string.IsNullOrWhiteSpace(input)) { groupAddress = "1/0/0"; break; }

            string[] parts = input.Trim().Split('/');
            if (parts.Length == 3 && int.TryParse(parts[0], out _) && int.TryParse(parts[1], out _) && int.TryParse(parts[2], out _))
            { groupAddress = input.Trim(); break; }

            Print(ConsoleColor.Red, "[!] Ungueltiges Format - bitte als Haupt/Mittel/Unter eingeben, z.B. 1/0/0");
        }

        bool value;
        while (true)
        {
            Console.ForegroundColor = ConsoleColor.Yellow;
            Console.Write("Wert senden - true oder false: ");
            Console.ForegroundColor = ConsoleColor.White;
            string? input = Console.ReadLine()?.Trim().ToLower();

            if (input == "true") { value = true; break; }
            if (input == "false") { value = false; break; }

            Print(ConsoleColor.Red, "[!] Bitte genau \"true\" oder \"false\" eingeben.");
        }

        Console.WriteLine();
        Connect(ip);
        SendDpt1(groupAddress, value);
        Disconnect();

        Console.WriteLine();
        Print(ConsoleColor.Cyan, "Fertig. Enter zum Beenden...");
        Console.ReadLine();
        Console.ResetColor();
    }

    static void Print(ConsoleColor color, string text)
    {
        Console.ForegroundColor = color;
        Console.WriteLine(text);
        Console.ResetColor();
    }

    static void Connect(string ip)
    {
        Print(ConsoleColor.Cyan, $"Verbinde zu {ip}:3671 ...");

        gatewayEndPoint = new IPEndPoint(IPAddress.Parse(ip), 3671);
        udp = new UdpClient(0) { Client = { ReceiveTimeout = 3000 } };
        localPort = ((IPEndPoint)udp.Client.LocalEndPoint).Port;

        using (var s = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, 0))
        {
            s.Connect(ip, 3671);
            localIp = ((IPEndPoint)s.LocalEndPoint).Address.ToString();
        }

        byte[] hpai = BuildHpai(localIp, localPort);
        byte[] cri = { 0x04, 0x04, 0x02, 0x00 };
        byte[] request = Concat(BuildHeader(0x0205, hpai.Length * 2 + cri.Length), hpai, hpai, cri);
        udp.Send(request, request.Length, gatewayEndPoint);

        byte[] response;
        try { IPEndPoint ep = new IPEndPoint(IPAddress.Any, 0); response = udp.Receive(ref ep); }
        catch (SocketException) { Print(ConsoleColor.Red, "[!] Keine Antwort vom Gateway."); return; }

        if (response.Length < 8 || response[2] != 0x02 || response[3] != 0x06 || response[7] != 0x00)
        { Print(ConsoleColor.Red, "[!] Verbindung abgelehnt."); return; }

        channelId = response[6];
        Print(ConsoleColor.Green, $"Verbunden. Channel ID: {channelId}");
    }

    static void SendDpt1(string groupAddress, bool value)
    {
        if (udp == null || gatewayEndPoint == null) { Print(ConsoleColor.Red, "[!] Nicht verbunden - kann nicht senden."); return; }

        Print(ConsoleColor.Cyan, $"Sende {groupAddress} = {value} ...");

        string[] parts = groupAddress.Split('/');
        int main = int.Parse(parts[0]), middle = int.Parse(parts[1]), sub = int.Parse(parts[2]);
        ushort ga = (ushort)(((main & 0x1F) << 11) | ((middle & 0x07) << 8) | (sub & 0xFF));

        byte[] cemi =
        {
            0x11, 0x00, 0xBC, 0xE0,
            0x00, 0x00,
            (byte)(ga >> 8), (byte)(ga & 0xFF),
            0x01, 0x00,
            (byte)(0x80 | (value ? 0x01 : 0x00))
        };

        byte[] connHeader = { 0x04, channelId, 0x00, 0x00 };
        byte[] request = Concat(BuildHeader(0x0420, connHeader.Length + cemi.Length), connHeader, cemi);
        udp.Send(request, request.Length, gatewayEndPoint);

        byte[] response;
        try { IPEndPoint ep = new IPEndPoint(IPAddress.Any, 0); response = udp.Receive(ref ep); }
        catch (SocketException) { Print(ConsoleColor.Red, "[!] Kein ACK erhalten."); return; }

        bool ok = response.Length >= 6 && response[2] == 0x04 && response[3] == 0x21;
        Print(ok ? ConsoleColor.Green : ConsoleColor.Red, ok ? $"{groupAddress} = {value} gesendet und bestaetigt." : "[!] Kein ACK erhalten.");
    }

    static void Disconnect()
    {
        if (udp == null || gatewayEndPoint == null) return;

        byte[] hpai = BuildHpai(localIp, localPort);
        byte[] body = Concat(new byte[] { channelId, 0x00 }, hpai);
        byte[] request = Concat(BuildHeader(0x0209, body.Length), body);
        udp.Send(request, request.Length, gatewayEndPoint);

        try { IPEndPoint ep = new IPEndPoint(IPAddress.Any, 0); udp.Receive(ref ep); }
        catch (SocketException) { /* keine Antwort ist unkritisch */ }

        udp.Dispose();
        Print(ConsoleColor.Cyan, "Verbindung getrennt.");
    }

    static byte[] BuildHeader(ushort serviceType, int bodyLength)
    {
        int totalLength = 6 + bodyLength;
        return new byte[] { 0x06, 0x10, (byte)(serviceType >> 8), (byte)(serviceType & 0xFF), (byte)(totalLength >> 8), (byte)(totalLength & 0xFF) };
    }

    static byte[] BuildHpai(string ip, int port)
    {
        byte[] ipBytes = IPAddress.Parse(ip).GetAddressBytes();
        return new byte[] { 0x08, 0x01, ipBytes[0], ipBytes[1], ipBytes[2], ipBytes[3], (byte)(port >> 8), (byte)(port & 0xFF) };
    }

    static byte[] Concat(params byte[][] arrays)
    {
        byte[] result = new byte[arrays.Sum(a => a.Length)];
        int offset = 0;
        foreach (var a in arrays) { Buffer.BlockCopy(a, 0, result, offset, a.Length); offset += a.Length; }
        return result;
    }
}

Offizielle Spezifikationen, Open Source Implementierungen zum Nachlesen und praktische Einführungen, falls du tiefer einsteigen willst als diese Anleitung.