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.
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:
- Tunnelling —> eine 1:1 Verbindung (Unicast) zwischen deinem PC und genau einem Gateway. Das ist das Verfahren dieser Anleitung.
- Routing —> Multicast-basiert, wird zwischen Linien-/Bereichskopplern verwendet, nicht für einzelne Clients gedacht.
Jeder KNXnet/IP Frame beginnt mit demselben 6 Byte Header, danach folgt ein service spezifischer Body:
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
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.
C# — Verbindung aufbauen
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
TUNNELING_ACK ← Gateway
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
// 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:
| DPT | Größe | Kodierung | Beispiel Bytes | Ergibt |
|---|---|---|---|---|
| 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" |
C# — DPT9 (Float) encodieren/decodieren
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
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.
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.
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;
}
}
07Weiterführende Links
Offizielle Spezifikationen, Open Source Implementierungen zum Nachlesen und praktische Einführungen, falls du tiefer einsteigen willst als diese Anleitung.
Herausgeber des KNX Standards. Vollständige Spezifikationen sind über den KNX Association Mitgliederbereich zugänglich.
Guter Überblick über Standardisierung (EN 50090, ISO/IEC 14543), Historie und Einsatzgebiete.
Verständliche Einführung in Tunnelling vs. Routing, aus der Praxis für Installateure geschrieben.
Kurze, klare Erklärung von Core-/Data-Kanal und den drei Verbindungsarten (Management/Tunnelling/Routing).
Ausgereifte Open-Source-Java-Bibliothek für KNX-Netzwerkzugriff inkl. Tunnelling, Routing, Secure und DPT-Codierung.
Quellcode zum Nachlesen, wie ein produktionsreifer Stack Frames, DPTs und Verbindungen implementiert.
Asynchrone Python-Bibliothek für KNX/IP, u. a. die Basis der KNX-Integration von Home Assistant.
DPT-Encoder/Decoder und Frame Aufbau im Quellcode nachvollziehen — gute Referenz für eigene Implementierungen.