Bevor man einen ESP32 dazu bringt, sauber mit einem KNX Gateway zu sprechen, hilft es zu wissen, welche Schichten ein KNX Telegramm durchläuft und wie es in ein UDP-Paket verpackt wird. Diese Seite erklärt den Stack und führt anschließend durch den eigenen Sketch.
KNX ist ein herstellerunabhängiger Bus-Standard für Gebäudeautomation (ISO/IEC 14543-3). Statt jedes Kabel einzeln vom Schalter zum Verbraucher zu ziehen, hängen alle Geräte – Taster, Sensoren, Aktoren, Gateways und an einer gemeinsamen zweiadrigen Busleitung und tauschen Telegramme aus.
Jedes Telegramm trägt eine Gruppenadresse (z. B. 1/0/0)
statt einer festen Geräteadresse. Jedes Gerät, das auf diese Gruppenadresse "hört", reagiert,
dadurch kann ein einzelner Taster beliebig viele Aktoren gleichzeitig schalten, ohne dass sie
sich kennen müssen.
Ähnlich wie beim OSI Modell wird ein Telegramm auf dem Weg zum Ziel durch mehrere Schichten gereicht. Für unseren ESP32 Sketch ist vor allem wichtig: die Application Layer entscheidet, was gesendet wird (z. B. "Licht an"), alles darunter kümmert sich nur noch darum, es zuverlässig zu transportieren.
KNXnet/IP verpackt das eigentliche Bus-Telegramm (im sogenannten cEMI-Frame) einfach in ein UDP Paket. Der ESP32 baut dazu eine Tunneling-Verbindung zum Gateway auf – genau die Schritte, die unser Sketch der Reihe nach durchläuft:
Bleibt der Heartbeat aus, trennt das Gateway die Verbindung nach ca. 120 Sekunden Inaktivität selbstständig, das ist der häufigste Grund, warum ein Tunnel "plötzlich" nicht mehr reagiert.
Die folgenden Ausschnitte stammen aus dem ESP32 Sketch und zeigen, wie die Protokollschritte aus Abschnitt 03 tatsächlich in C++ aussehen.
Eine KNX Gruppenadresse Hauptgruppe/Mittelgruppe/Untergruppe
wird als 5+3+8 Bit in einem einzigen 16Bit Wert kodiert.
uint16_t parseGroupAddress(const String& gaStr) {
int firstSlash = gaStr.indexOf('/');
int secondSlash = gaStr.indexOf('/', firstSlash + 1);
uint8_t main = gaStr.substring(0, firstSlash).toInt();
uint8_t middle = gaStr.substring(firstSlash + 1, secondSlash).toInt();
uint8_t sub = gaStr.substring(secondSlash + 1).toInt();
return ((main & 0x1F) << 11) | ((middle & 0x07) << 8) | (sub & 0xFF);
}
Zwei HPAI-Blöcke (Control- und Data-Endpoint) melden dem Gateway, an welche IP/Port es antworten soll. Die CRI am Ende fordert explizit eine Tunneling-Verbindung im Linklayer-Modus an.
uint8_t connReq[] = {
0x06, 0x10, 0x02, 0x05, 0x00, 0x1A,
0x08, 0x01, myIp[0], myIp[1], myIp[2], myIp[3],
(uint8_t)(localPort >> 8), (uint8_t)(localPort & 0xFF),
0x08, 0x01, myIp[0], myIp[1], myIp[2], myIp[3],
(uint8_t)(localPort >> 8), (uint8_t)(localPort & 0xFF),
0x04, 0x04, 0x02, 0x00
};
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(connReq, sizeof(connReq));
udp.endPacket();
Der cEMI-Frame trägt Control Felder, Ziel-Gruppenadresse und Nutzdaten. Bei
DPT1 steckt der eigentliche Wert direkt im APCI-Byte: 0x80 = AUS,
0x81 = EIN.
buf[10] = 0x11; // Message Code: L_Data.req
buf[11] = 0x00; // Additional Info Length
buf[12] = 0xBC; // Control Field 1 (Standard, Broadcast)
buf[13] = 0xE0; // Control Field 2 (Gruppenadresse, Hop Count 6)
buf[16] = (uint8_t)(ga >> 8);
buf[17] = (uint8_t)(ga & 0xFF);
buf[18] = 0x01; // NPDU Länge
buf[19] = 0x00; // TPCI
buf[20] = (rawValue.toInt() > 0) ? 0x81 : 0x80; // APCI + Wert (DPT1)
Ohne aktives Warten weiß der Sketch nie, ob ein Telegramm wirklich angekommen
ist. Deshalb wird nach dem Senden kurz auf ein passendes TUNNELING_ACK
gehört und mit der gleichen Sequenznummer, die gerade verschickt wurde.
while (millis() - waitStart < ACK_TIMEOUT) {
int packetSize = udp.parsePacket();
if (packetSize > 0) {
uint8_t rxBuf[64];
int len = udp.read(rxBuf, sizeof(rxBuf));
uint16_t st = processIncomingPacket(rxBuf, len);
if (st == 0x0421 && rxBuf[8] == sentSeq) {
ackOk = (rxBuf[9] == 0x00); // Status 0x00 = E_NO_ERROR
break;
}
}
delay(5);
}
Die im Sketch unterstützten Datenpunkttypen, erkennbar an der Byte Länge der Nutzdaten (NPDU-Länge), genau das, was der Telegrammverlauf im Webinterface zur automatischen Erkennung nutzt.
| DPT | Bedeutung | Bytes | Beispielwert |
|---|---|---|---|
| DPT1 | 1 Bit, Schalten | 1 | 0 / 1 |
| DPT5 | 1 Byte, vorzeichenlos | 2 | 0…255 |
| DPT9 | 2 Byte Gleitkomma | 3 | 21.50 |
| DPT10 | Uhrzeit | 4 | 14:30:00 |
| DPT11 | Datum | 4 | 24.12.26 |
| DPT14 | 4 Byte IEEE754-Float | 5 | 21.503 |
| DPT16 | Text, fix 14 Byte | 15 | "Hallo" |
knxStatusToString()
im Sketch nachschlagen, z. B. 0x21 = unbekannte Channel ID → Reconnect auslösen.Der komplette Sketch in einer Datei, inklusive WLAN- und NTP Setup, Verbindungsaufbau, Senden mit ACK Warten, automatischer Erkennung eingehender Telegramme (unterstützt DPT 1, 5, 9, 10, 11, 14, 16) sowie einem integrierten Webinterface mit Live Verlauf.
#include <WiFi.h>
#include <WiFiUdp.h>
#include <WebServer.h>
#include <time.h>
const char* projectName = "KNX Stack";
const char* ssid = "WLAN SSID";
const char* password = "WLAN Passwort";
const char* ntpServer = "pool.ntp.org";
const char* tzInfo = "CET-1CEST,M3.5.0,M10.5.0/3";
const char* knxGatewayIp = "IP KNX Gateway";
const uint16_t knxPort = 3671;
const uint16_t localPort = 3671;
WiFiUDP udp;
WebServer server(80);
uint8_t knxChannelId = 0;
uint8_t txSeqNum = 0;
bool isKnxConnected = false;
unsigned long lastHeartbeat = 0;
const unsigned long HEARTBEAT_INTERVAL = 60000;
const unsigned long ACK_TIMEOUT = 1000;
#define KNX_LOG_SIZE 10
struct KnxLogEntry {
char timeStr[24];
uint16_t ga;
uint8_t data[16];
uint8_t len;
};
KnxLogEntry knxLog[KNX_LOG_SIZE];
int knxLogNext = 0;
int knxLogFilled = 0;
void hexDump(const char* label, const uint8_t* buf, int len) {
Serial.printf("%s [%d Bytes]: ", label, len);
for (int i = 0; i < len; i++) {
Serial.printf("%02X ", buf[i]);
}
Serial.println();
}
const char* knxStatusToString(uint8_t status) {
switch (status) {
case 0x00: return "E_NO_ERROR";
case 0x01: return "E_HOST_PROTOCOL_TYPE";
case 0x02: return "E_VERSION_NOT_SUPPORTED";
case 0x04: return "E_SEQUENCE_NUMBER (Sequenznummer falsch/ausser Sync)";
case 0x21: return "E_CONNECTION_ID (unbekannte Channel ID)";
case 0x22: return "E_CONNECTION_TYPE";
case 0x23: return "E_CONNECTION_OPTION";
case 0x24: return "E_NO_MORE_CONNECTIONS (Gateway voll)";
case 0x26: return "E_DATA_CONNECTION";
case 0x27: return "E_KNX_CONNECTION";
case 0x29: return "E_TUNNELING_LAYER (nicht unterstuetzt)";
default: return "Unbekannter Statuscode";
}
}
String getFormattedDateTime() {
struct tm timeinfo;
if (!getLocalTime(&timeinfo, 100)) {
return "Zeit nicht synchronisiert";
}
char buf[32];
strftime(buf, sizeof(buf), "%d.%m.%Y %H:%M:%S", &timeinfo);
return String(buf);
}
uint16_t parseGroupAddress(const String& gaStr) {
int firstSlash = gaStr.indexOf('/');
int secondSlash = gaStr.indexOf('/', firstSlash + 1);
if (firstSlash == -1 || secondSlash == -1) return 0;
uint8_t main = gaStr.substring(0, firstSlash).toInt();
uint8_t middle = gaStr.substring(firstSlash + 1, secondSlash).toInt();
uint8_t sub = gaStr.substring(secondSlash + 1).toInt();
return ((main & 0x1F) << 11) | ((middle & 0x07) << 8) | (sub & 0xFF);
}
String gaToString(uint16_t ga) {
uint8_t main = (ga >> 11) & 0x1F;
uint8_t middle = (ga >> 8) & 0x07;
uint8_t sub = ga & 0xFF;
return String(main) + "/" + String(middle) + "/" + String(sub);
}
uint16_t floatToDpt9(float val) {
int sign = (val < 0) ? 1 : 0;
float v = (sign == 1) ? -val : val;
int exp = 0;
int mantissa = (int)(v * 100.0f);
while (mantissa > 2047) {
mantissa >>= 1;
exp++;
}
if (sign == 1) {
mantissa = -mantissa;
mantissa &= 0x07FF;
}
return (sign << 15) | ((exp & 0x0F) << 11) | (mantissa & 0x07FF);
}
float dpt9ToFloat(uint8_t hi, uint8_t lo) {
uint16_t raw = ((uint16_t)hi << 8) | lo;
int sign = (raw >> 15) & 0x01;
int exp = (raw >> 11) & 0x0F;
int mantissa11 = raw & 0x07FF;
int M = mantissa11 - (sign ? 2048 : 0);
return 0.01f * M * (float)(1 << exp);
}
void floatToDpt14Bytes(float val, uint8_t* out4) {
uint8_t* p = (uint8_t*)&val;
out4[0] = p[3];
out4[1] = p[2];
out4[2] = p[1];
out4[3] = p[0];
}
float dpt14ToFloat(uint8_t b0, uint8_t b1, uint8_t b2, uint8_t b3) {
uint8_t bytes[4] = { b3, b2, b1, b0 };
float f;
memcpy(&f, bytes, 4);
return f;
}
String apciTypeStr(uint8_t apciByte) {
switch (apciByte & 0xC0) {
case 0x00: return "Read";
case 0x40: return "Response";
case 0x80: return "Write";
default: return "?";
}
}
String interpretKnxData(uint8_t* data, uint8_t len) {
String prefix = "[" + apciTypeStr(data[0]) + "] ";
if (len == 1) {
bool bitVal = data[0] & 0x01;
return prefix + "DPT1: " + (bitVal ? "EIN (1)" : "AUS (0)");
}
else if (len == 2) {
return prefix + "DPT5: " + String(data[1]) + " (0..255)";
}
else if (len == 3) {
float f = dpt9ToFloat(data[1], data[2]);
return prefix + "DPT9: " + String(f, 2);
}
else if (len == 4) {
uint8_t h = data[1] & 0x1F, mnt = data[2] & 0x3F, s = data[3] & 0x3F;
uint8_t day = data[1] & 0x1F, month = data[2] & 0x0F, year = data[3] & 0x7F;
char buf[80];
snprintf(buf, sizeof(buf), "DPT10 Zeit: %02d:%02d:%02d / DPT11 Datum: %02d.%02d.20%02d",
h, mnt, s, day, month, year);
return prefix + String(buf);
}
else if (len == 5) {
float f = dpt14ToFloat(data[1], data[2], data[3], data[4]);
return prefix + "DPT14: " + String(f, 3);
}
else if (len == 15) {
char txt[15];
for (int i = 0; i < 14; i++) {
uint8_t c = data[1 + i];
txt[i] = (c >= 32 && c < 127) ? (char)c : (c == 0 ? 0 : '.');
}
txt[14] = 0;
return prefix + "DPT16: \"" + String(txt) + "\"";
}
return prefix + "Unbekannt (" + String(len) + " Byte)";
}
void addKnxLogEntry(uint16_t ga, uint8_t* rxBuf, uint8_t npduLen) {
KnxLogEntry &e = knxLog[knxLogNext];
String t = getFormattedDateTime();
strncpy(e.timeStr, t.c_str(), sizeof(e.timeStr) - 1);
e.timeStr[sizeof(e.timeStr) - 1] = 0;
e.ga = ga;
uint8_t copyLen = npduLen;
if (copyLen > sizeof(e.data)) copyLen = sizeof(e.data);
memcpy(e.data, &rxBuf[20], copyLen);
e.len = copyLen;
knxLogNext = (knxLogNext + 1) % KNX_LOG_SIZE;
if (knxLogFilled < KNX_LOG_SIZE) knxLogFilled++;
}
uint16_t processIncomingPacket(uint8_t* rxBuf, int len) {
if (len < 6) return 0;
uint16_t serviceType = (rxBuf[2] << 8) | rxBuf[3];
hexDump("[KNX RX]", rxBuf, len);
switch (serviceType) {
case 0x0421:
if (len >= 10) {
Serial.printf(" -> TUNNELING_ACK | Channel: %d | Seq: %d | Status: 0x%02X (%s)\n",
rxBuf[7], rxBuf[8], rxBuf[9], knxStatusToString(rxBuf[9]));
}
break;
case 0x0420:
if (len >= 21) {
uint8_t rxChannel = rxBuf[7];
uint8_t rxSeqNum = rxBuf[8];
uint16_t ga = ((uint16_t)rxBuf[16] << 8) | rxBuf[17];
uint8_t npduLen = rxBuf[18];
Serial.printf(" -> TUNNELING_REQUEST (Bus-Telegramm) | Channel: %d | Seq: %d | GA: %s\n",
rxChannel, rxSeqNum, gaToString(ga).c_str());
Serial.println(" " + interpretKnxData(&rxBuf[20], npduLen));
if (rxChannel == knxChannelId) {
addKnxLogEntry(ga, rxBuf, npduLen);
uint8_t ackBuf[10] = {
0x06, 0x10, 0x04, 0x21, 0x00, 0x0A, 0x04, knxChannelId, rxSeqNum, 0x00
};
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(ackBuf, sizeof(ackBuf));
udp.endPacket();
hexDump("[KNX TX] ACK an Bus-Telegramm", ackBuf, sizeof(ackBuf));
}
}
break;
case 0x0208:
if (len >= 8) {
Serial.printf(" -> CONNECTIONSTATE_RESPONSE | Status: 0x%02X (%s)\n", rxBuf[7], knxStatusToString(rxBuf[7]));
if (rxBuf[7] != 0x00) {
Serial.println(" !!! Heartbeat fehlgeschlagen -> Tunnel als getrennt markiert !!!");
isKnxConnected = false;
}
}
break;
case 0x0209:
if (len >= 8) {
uint8_t rxChannel = rxBuf[6];
Serial.printf(" -> DISCONNECT_REQUEST vom Gateway | Channel: %d\n", rxChannel);
if (rxChannel == knxChannelId) {
uint8_t discResp[8] = { 0x06, 0x10, 0x02, 0x0A, 0x00, 0x08, knxChannelId, 0x00 };
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(discResp, sizeof(discResp));
udp.endPacket();
hexDump("[KNX TX] DISCONNECT_RESPONSE", discResp, sizeof(discResp));
isKnxConnected = false;
}
}
break;
default:
Serial.printf(" -> Unbehandelter ServiceType: 0x%04X\n", serviceType);
break;
}
return serviceType;
}
bool connectKnx() {
Serial.println("[KNX] Sende CONNECT_REQUEST an Gateway...");
IPAddress myIp = WiFi.localIP();
Serial.printf(" Lokale IP/Port die wir dem Gateway melden: %s:%d\n", myIp.toString().c_str(), localPort);
Serial.printf(" Ziel-Gateway: %s:%d\n", knxGatewayIp, knxPort);
uint8_t connReq[] = {
0x06, 0x10, 0x02, 0x05, 0x00, 0x1A,
0x08, 0x01, myIp[0], myIp[1], myIp[2], myIp[3], (uint8_t)(localPort >> 8), (uint8_t)(localPort & 0xFF),
0x08, 0x01, myIp[0], myIp[1], myIp[2], myIp[3], (uint8_t)(localPort >> 8), (uint8_t)(localPort & 0xFF),
0x04, 0x04, 0x02, 0x00
};
hexDump("[KNX TX] CONNECT_REQUEST", connReq, sizeof(connReq));
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(connReq, sizeof(connReq));
udp.endPacket();
unsigned long start = millis();
while (millis() - start < 2000) {
int size = udp.parsePacket();
if (size >= 8) {
uint8_t resp[32];
int rlen = udp.read(resp, sizeof(resp));
hexDump("[KNX RX] CONNECT_RESPONSE", resp, rlen);
if (resp[2] == 0x02 && resp[3] == 0x06) {
if (resp[7] == 0x00) {
knxChannelId = resp[6];
txSeqNum = 0;
isKnxConnected = true;
lastHeartbeat = millis();
Serial.printf("ERFOLGREICH! Channel ID: %d\n", knxChannelId);
if (rlen >= 16 && resp[8] == 0x08) {
IPAddress dataIp(resp[10], resp[11], resp[12], resp[13]);
uint16_t dataPort = (resp[14] << 8) | resp[15];
Serial.printf(" [Info] Gateway Data-Endpoint laut CONNECT_RESPONSE: %s:%d\n",
dataIp.toString().c_str(), dataPort);
}
return true;
} else {
Serial.printf("FEHLER! Status Code: 0x%02X (%s)\n", resp[7], knxStatusToString(resp[7]));
return false;
}
}
}
delay(10);
}
Serial.println("TIMEOUT! Keine CONNECT_RESPONSE vom Gateway erhalten.");
isKnxConnected = false;
return false;
}
void sendConnectionStateRequest() {
IPAddress myIp = WiFi.localIP();
uint8_t req[16] = {
0x06, 0x10, 0x02, 0x07, 0x00, 0x10,
knxChannelId, 0x00,
0x08, 0x01, myIp[0], myIp[1], myIp[2], myIp[3], (uint8_t)(localPort >> 8), (uint8_t)(localPort & 0xFF)
};
hexDump("[KNX TX] CONNECTIONSTATE_REQUEST (Heartbeat)", req, sizeof(req));
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(req, sizeof(req));
udp.endPacket();
}
void disconnectKnx() {
if (!isKnxConnected) return;
IPAddress myIp = WiFi.localIP();
uint8_t req[16] = {
0x06, 0x10, 0x02, 0x09, 0x00, 0x10,
knxChannelId, 0x00,
0x08, 0x01, myIp[0], myIp[1], myIp[2], myIp[3], (uint8_t)(localPort >> 8), (uint8_t)(localPort & 0xFF)
};
hexDump("[KNX TX] DISCONNECT_REQUEST", req, sizeof(req));
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(req, sizeof(req));
udp.endPacket();
isKnxConnected = false;
}
void handleKnxRx() {
int packetSize = udp.parsePacket();
if (packetSize <= 0) return;
uint8_t rxBuf[64];
int len = udp.read(rxBuf, sizeof(rxBuf));
processIncomingPacket(rxBuf, len);
}
bool sendKnxValue(String groupAddress, String dptType, String rawValue) {
if (!isKnxConnected) {
if (!connectKnx()) return false;
}
uint16_t ga = parseGroupAddress(groupAddress);
uint8_t buf[64];
uint8_t payloadLen = 0;
buf[0] = 0x06; buf[1] = 0x10;
buf[2] = 0x04; buf[3] = 0x20;
buf[4] = 0x00; buf[5] = 0x00;
buf[6] = 0x04;
buf[7] = knxChannelId;
buf[8] = txSeqNum;
buf[9] = 0x00;
buf[10] = 0x11;
buf[11] = 0x00;
buf[12] = 0xBC;
buf[13] = 0xE0;
buf[14] = 0x00; buf[15] = 0x00;
buf[16] = (uint8_t)(ga >> 8);
buf[17] = (uint8_t)(ga & 0xFF);
if (dptType == "DPT1") {
buf[18] = 0x01;
buf[19] = 0x00;
buf[20] = (rawValue.toInt() > 0) ? 0x81 : 0x80;
payloadLen = 21;
}
else if (dptType == "DPT5") {
buf[18] = 0x02;
buf[19] = 0x00;
buf[20] = 0x80;
buf[21] = (uint8_t)rawValue.toInt();
payloadLen = 22;
}
else if (dptType == "DPT9") {
uint16_t dpt9Val = floatToDpt9(rawValue.toFloat());
buf[18] = 0x03;
buf[19] = 0x00;
buf[20] = 0x80;
buf[21] = (uint8_t)(dpt9Val >> 8);
buf[22] = (uint8_t)(dpt9Val & 0xFF);
payloadLen = 23;
}
else if (dptType == "DPT10") {
int h = 0, m = 0, s = 0;
int c1 = rawValue.indexOf(':');
if (c1 == -1) {
h = rawValue.toInt();
} else {
h = rawValue.substring(0, c1).toInt();
int c2 = rawValue.indexOf(':', c1 + 1);
if (c2 == -1) {
m = rawValue.substring(c1 + 1).toInt();
} else {
m = rawValue.substring(c1 + 1, c2).toInt();
s = rawValue.substring(c2 + 1).toInt();
}
}
buf[18] = 0x04;
buf[19] = 0x00;
buf[20] = 0x80;
buf[21] = (uint8_t)(h & 0x1F);
buf[22] = (uint8_t)(m & 0x3F);
buf[23] = (uint8_t)(s & 0x3F);
payloadLen = 24;
}
else if (dptType == "DPT11") {
int day = 1, month = 1, year = 26;
int p1 = rawValue.indexOf('.');
if (p1 != -1) {
day = rawValue.substring(0, p1).toInt();
int p2 = rawValue.indexOf('.', p1 + 1);
if (p2 != -1) {
month = rawValue.substring(p1 + 1, p2).toInt();
int y = rawValue.substring(p2 + 1).toInt();
year = (y >= 2000) ? (y - 2000) : y;
}
} else {
day = rawValue.toInt();
}
buf[18] = 0x04;
buf[19] = 0x00;
buf[20] = 0x80;
buf[21] = (uint8_t)(day & 0x1F);
buf[22] = (uint8_t)(month & 0x0F);
buf[23] = (uint8_t)(year & 0x7F);
payloadLen = 24;
}
else if (dptType == "DPT14") {
uint8_t db[4];
floatToDpt14Bytes(rawValue.toFloat(), db);
buf[18] = 0x05;
buf[19] = 0x00;
buf[20] = 0x80;
buf[21] = db[0];
buf[22] = db[1];
buf[23] = db[2];
buf[24] = db[3];
payloadLen = 25;
}
else if (dptType == "DPT16") {
uint8_t strLen = rawValue.length();
if (strLen > 14) strLen = 14;
buf[18] = 15;
buf[19] = 0x00;
buf[20] = 0x80;
for (int i = 0; i < 14; i++) {
buf[21 + i] = (i < strLen) ? rawValue[i] : 0x00;
}
payloadLen = 21 + 14;
}
else {
Serial.println("[KNX TX] FEHLER: Unbekannter DPT-Typ!");
return false;
}
buf[5] = payloadLen;
Serial.printf("[KNX TX] GA: %s (0x%04X) | DPT: %s | Val: %s | Seq: %d\n",
groupAddress.c_str(), ga, dptType.c_str(), rawValue.c_str(), txSeqNum);
hexDump("[KNX TX] TUNNELING_REQUEST", buf, payloadLen);
uint8_t sentSeq = txSeqNum;
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(buf, payloadLen);
udp.endPacket();
txSeqNum++;
unsigned long waitStart = millis();
bool ackOk = false;
while (millis() - waitStart < ACK_TIMEOUT) {
int packetSize = udp.parsePacket();
if (packetSize > 0) {
uint8_t rxBuf[64];
int len = udp.read(rxBuf, sizeof(rxBuf));
uint16_t st = processIncomingPacket(rxBuf, len);
if (st == 0x0421 && len >= 9 && rxBuf[8] == sentSeq) {
ackOk = (len >= 10 && rxBuf[9] == 0x00);
break;
}
}
delay(5);
}
if (!ackOk) {
Serial.println(" !!! KEIN passendes TUNNELING_ACK erhalten (Timeout nach 1000ms)! !!!");
}
return ackOk;
}
String escapeJson(String s) {
String out;
for (size_t i = 0; i < s.length(); i++) {
char c = s[i];
if (c == '"' || c == '\\') out += '\\';
out += c;
}
return out;
}
void handleRoot() {
String html = "<html><head><meta charset='UTF-8'>";
html += "<title>" + String(projectName) + "</title>";
html += "<style>body{font-family:Arial,sans-serif;margin:20px;background:#f4f4f9;color:#333}";
html += ".card{background:#fff;padding:20px;border-radius:8px;box-shadow:0 2px 5px rgba(0,0,0,0.1);max-width:650px;margin:auto}";
html += "h1{text-align:center;margin-bottom:2px;}";
html += "#dtNow{text-align:center;color:#666;margin-top:0;margin-bottom:16px;font-size:14px;}";
html += "input,select,button{width:100%;padding:10px;margin:10px 0;box-sizing:border-box;border:1px solid #ccc;border-radius:4px;font-size:16px}";
html += "button{background:#007bff;color:#fff;font-weight:bold;border:none;cursor:pointer}button:hover{background:#0056b3}";
html += "#statusMsg{margin-top:10px;font-weight:bold;color:#28a745;text-align:center;}";
html += "table{width:100%;border-collapse:collapse;font-size:13px;margin-top:10px;}";
html += "th,td{border-bottom:1px solid #eee;padding:6px 4px;text-align:left;}";
html += "th{background:#f0f0f5;}";
html += "</style></head><body>";
html += "<div class='card'>";
html += "<h1>" + String(projectName) + "</h1>";
html += "<p id='dtNow'>...</p>";
html += "<p>KNX Status: <span id='knxStatus'>" + String(isKnxConnected ? "<b style='color:green;'>Verbunden (Channel " + String(knxChannelId) + ")" : "Getrennt") + "";
html += "<label>Gruppenadresse (z.B. 1/0/0):</label>";
html += "<input type='text' id='ga' value='1/0/0'>";
html += "<label>Datenpunkttyp (DPT):</label>";
html += "<select id='dpt'>";
html += "<option value='DPT1'>DPT 1 (1 Bit - Schalten 0/1)</option>";
html += "<option value='DPT5'>DPT 5 (1 Byte - Wert 0..255)</option>";
html += "<option value='DPT9'>DPT 9 (2 Byte Float - Temp/Wert)</option>";
html += "<option value='DPT10'>DPT 10 (Zeit, z.B. 14:30:00)</option>";
html += "<option value='DPT11'>DPT 11 (Datum, z.B. 24.12.26)</option>";
html += "<option value='DPT14'>DPT 14 (4 Byte Float - IEEE754)</option>";
html += "<option value='DPT16'>DPT 16 (14 Byte Text)</option>";
html += "</select>";
html += "<label>Wert / Payload:</label>";
html += "<input type='text' id='val' value='1' placeholder='z.B. 1, 128, 21.5, 14:30:00, 24.12.26 oder Text'>";
html += "<button type='button' onclick='sendTelegram()'>KNX Telegramm Senden</button>";
html += "<button type='button' onclick='reconnectKnx()' style='background:#6c757d;'>KNX Reconnect</button>";
html += "<div id='statusMsg'></div>";
html += "<h3>Letzte empfangene Telegramme</h3>";
html += "<table><thead><tr><th>Zeit</th><th>GA</th><th>Bytes</th><th>Hex</th><th>Wert</th></tr></thead>";
html += "<tbody id='logBody'>Lade...</td></tr></tbody></table>";
html += "</div>";
html += "<script>";
html += "function sendTelegram(){";
html += " var ga = document.getElementById('ga').value;";
html += " var dpt = document.getElementById('dpt').value;";
html += " var val = document.getElementById('val').value;";
html += " document.getElementById('statusMsg').innerText = 'Sende...';";
html += " fetch('/send?ga=' + encodeURIComponent(ga) + '&dpt=' + encodeURIComponent(dpt) + '&val=' + encodeURIComponent(val))";
html += " .then(response => response.text())";
html += " .then(data => { document.getElementById('statusMsg').innerText = data; })";
html += " .catch(err => { document.getElementById('statusMsg').innerText = 'Fehler beim Senden!'; });";
html += "}";
html += "function reconnectKnx(){";
html += " fetch('/reconnect').then(r=>r.text()).then(d=>{ updateStatus(); });";
html += "}";
html += "function updateStatus(){";
html += " fetch('/status').then(r=>r.json()).then(d=>{";
html += " document.getElementById('dtNow').innerText = d.datetime;";
html += " document.getElementById('knxStatus').innerHTML = d.connected ? (\"<b style='color:green;'>Verbunden (Channel \" + d.channel + \")</b>\") : \"<b style='color:red;'>Getrennt</b>\";";
html += " var rows = '';";
html += " d.log.forEach(function(e){ rows += '<tr><td>'+e.time+'</td><td>'+e.ga+'</td><td>'+e.len+'</td><td>'+e.hex+'</td><td>'+e.value+'</td></tr>'; });";
html += " document.getElementById('logBody').innerHTML = rows || '<tr><td colspan=5>Noch keine Telegramme empfangen</td></tr>';";
html += " }).catch(function(){});";
html += "}";
html += "setInterval(updateStatus, 1000);";
html += "updateStatus();";
html += "</script>";
html += "</body></html>";
server.send(200, "text/html", html);
}
void handleSend() {
if (server.hasArg("ga") && server.hasArg("dpt") && server.hasArg("val")) {
String ga = server.arg("ga");
String dpt = server.arg("dpt");
String val = server.arg("val");
bool ok = sendKnxValue(ga, dpt, val);
server.send(200, "text/plain", ok ? "Telegramm gesendet und per ACK bestaetigt!" : "Gesendet, aber KEIN ACK erhalten - siehe Serial-Monitor!");
} else {
server.send(400, "text/plain", "Fehlende Parameter!");
}
}
void handleReconnect() {
connectKnx();
server.send(200, "text/plain", "Reconnected!");
}
void handleStatus() {
String json = "{";
json += "\"project\":\"" + escapeJson(String(projectName)) + "\",";
json += "\"datetime\":\"" + escapeJson(getFormattedDateTime()) + "\",";
json += "\"connected\":" + String(isKnxConnected ? "true" : "false") + ",";
json += "\"channel\":" + String(knxChannelId) + ",";
json += "\"log\":[";
for (int i = 0; i < knxLogFilled; i++) {
int idx = (knxLogNext - 1 - i + KNX_LOG_SIZE) % KNX_LOG_SIZE;
KnxLogEntry &e = knxLog[idx];
String hex = "";
for (int b = 0; b < e.len; b++) {
char hb[4];
snprintf(hb, sizeof(hb), "%02X ", e.data[b]);
hex += hb;
}
hex.trim();
if (i > 0) json += ",";
json += "{";
json += "\"time\":\"" + escapeJson(String(e.timeStr)) + "\",";
json += "\"ga\":\"" + escapeJson(gaToString(e.ga)) + "\",";
json += "\"len\":" + String(e.len) + ",";
json += "\"hex\":\"" + escapeJson(hex) + "\",";
json += "\"value\":\"" + escapeJson(interpretKnxData(e.data, e.len)) + "\"";
json += "}";
}
json += "]}";
server.send(200, "application/json", json);
}
void setup() {
Serial.begin(115200);
delay(1000);
Serial.printf("\n===> %s <===\n", projectName);
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWLAN Verbunden!");
Serial.print("IP Adresse: "); Serial.println(WiFi.localIP());
Serial.print("RSSI: "); Serial.println(WiFi.RSSI());
configTzTime(tzInfo, ntpServer);
Serial.println("Warte auf Zeitsynchronisation (NTP)...");
struct tm timeinfo;
if (getLocalTime(&timeinfo, 5000)) {
Serial.println("Zeit synchronisiert: " + getFormattedDateTime());
} else {
Serial.println("Zeit konnte nicht synchronisiert werden (wird spaeter automatisch nachgeholt).");
}
udp.begin(localPort);
connectKnx();
server.on("/", handleRoot);
server.on("/send", handleSend);
server.on("/reconnect", handleReconnect);
server.on("/status", handleStatus);
server.begin();
}
void loop() {
server.handleClient();
handleKnxRx();
if (isKnxConnected && (millis() - lastHeartbeat > HEARTBEAT_INTERVAL)) {
sendConnectionStateRequest();
lastHeartbeat = millis();
}
}
08
Code: ESP32 KNX Stack - Dashboard im Home Assistant Style
Der komplette Sketch in einer Datei, inklusive WLAN- und NTP Setup,
Verbindungsaufbau, Senden mit ACK Warten, automatischer Erkennung eingehender
Telegramme (unterstützt DPT 1, 2, 3, 4, 5, 9, 10, 11, 14, 16) sowie
einem integrierten Webinterface mit Live Verlauf.
Dieser Code stellt ein modernes Smart Home Dashboard im Stil von Home Assistant dar,
komplett aufgeteilt in übersichtliche Kacheln für verschiedene Zimmer sowie
eine integrierte Live Kamera Kachel.
ESP32_KNX_Dashboard.ino
#include <WiFi.h>
#include <WiFiUdp.h>
#include <WebServer.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <time.h>
#include <math.h>
// ===============================================================================================================================
// PROJEKT / WLAN / NTP
// ===============================================================================================================================
const char* projectName = "KNX Dashboard";
const char* ssid = "WLAN SSID";
const char* password = "WALAN PASSWORT";
const char* ntpServer = "pool.ntp.org";
const char* tzInfo = "CET-1CEST,M3.5.0,M10.5.0/3";
// ===============================================================================================================================
// KNX GATEWAY
// ===============================================================================================================================
const char* knxGatewayIp = "IP address KNX gateway";
const uint16_t knxPort = 3671;
const uint16_t localPort = 3671;
WiFiUDP udp;
WebServer server(80);
uint8_t knxChannelId = 0;
uint8_t txSeqNum = 0;
bool isKnxConnected = false;
unsigned long lastHeartbeat = 0;
const unsigned long HEARTBEAT_INTERVAL = 60000;
const unsigned long ACK_TIMEOUT = 1000;
// ===============================================================================================================================
// RAUM / GRUPPENADRESSEN KONFIGURATION
// ===============================================================================================================================
struct SwitchGA { const char* label; const char* gaWrite; const char* gaRead; };
struct DimGA { const char* label; const char* gaRelWrite; const char* gaValWrite; const char* gaValRead; };
struct ShutterGA { const char* label; const char* gaMove; const char* gaPosWrite; const char* gaPosRead; };
struct SensorGA { const char* label; const char* ga; const char* unit; };
// Küche
SwitchGA kuecheDeckenlampe = { "Spots", "1/0/0", "10/0/3" };
SwitchGA kuecheSpots = { "Deckenlampe", "1/0/1", "10/0/2" };
ShutterGA kuecheRolladenLinks = { "Rolladen links", "6/0/7", "6/0/9", "10/4/22" };
ShutterGA kuecheRolladenRechts = { "Rolladen rechts", "6/0/11", "6/0/13", "10/4/27" };
// Esszimmer
SwitchGA esszimmerDeckeSwitch = { "Deckenlicht", "1/1/0", "10/0/4" };
DimGA esszimmerDeckeDim = { "Deckenlicht dimmen", "1/1/1", "1/1/2", "10/0/0" };
// Wohnzimmer
SwitchGA wohnzimmerDeckeSwitch = { "Deckenlicht", "1/2/0", "10/0/5" };
DimGA wohnzimmerDeckeDim = { "Deckenlicht dimmen", "1/2/1", "1/2/2", "10/0/1" };
ShutterGA wohnzimmerRolladen = { "Rolladen", "6/0/0", "6/0/14", "10/4/2" };
// Wetterstation (nur Anzeige)
SensorGA wetterSensoren[] = {
{ "Helligkeit vorne", "11/7/0", "lx" },
{ "Helligkeit links", "11/7/1", "lx" },
{ "Helligkeit rechts", "11/7/2", "lx" },
{ "Max. Helligkeit", "11/7/3", "lx" },
{ "Temperatur", "11/7/4", "°C" },
{ "Windgeschwindigkeit", "11/7/5", "m/s" },
};
const int wetterSensorenCount = 6;
// Energie (nur Anzeige)
SensorGA energieSensoren[] = {
{ "Spannung L1", "11/4/15", "V" },
{ "Spannung L2", "11/4/18", "V" },
{ "Spannung L3", "11/4/21", "V" },
{ "Strom L1", "11/4/17", "A" },
{ "Strom L2", "11/4/20", "A" },
{ "Strom L3", "11/4/23", "A" },
{ "Leistung L1", "11/4/16", "W" },
{ "Leistung L2", "11/4/19", "W" },
{ "Leistung L3", "11/4/22", "W" },
{ "Gesamtleistung", "11/4/24", "W" },
};
const int energieSensorenCount = 10;
// Raumtemperaturen (nur Anzeige)
SensorGA raumTemperaturen[] = {
{ "Kueche/Esszimmer", "4/1/3", "°C" },
{ "Wohnzimmer", "4/1/2", "°C" },
{ "Eltern-Schlafzimmer", "4/3/0", "°C" },
{ "Schlafzimmer Freddie", "4/3/1", "°C" },
{ "Buero", "4/3/2", "°C" },
{ "Schlafzimmer Toni", "4/2/2", "°C" },
{ "Schlafzimmer Benni", "4/2/1", "°C" },
{ "Badezimmer", "4/2/0", "°C" },
{ "Treppenhaus", "4/2/3", "°C" },
};
const int raumTemperaturenCount = 9;
// Luftfeuchtigkeit (nur Anzeige)
SensorGA luftfeuchteSensoren[] = {
{ "Esszimmer", "4/1/4", "%" },
{ "Wohnzimmer", "4/1/5", "%" },
{ "Flur hinten", "4/1/7", "%" },
};
const int luftfeuchteSensorenCount = 3;
// ===============================================================================================================================
// KAMERA (Mobotix T25) - MJPEG Stream, direkt vom Browser
// geladen (der ESP32 leitet nichts durch).
// ===============================================================================================================================
const char* cameraStreamUrl = "http://192.168.178.43/cgi-bin/faststream.jpg?stream=full&needlength";
// ===============================================================================================================================
// WETTER WIDGET (Open-Meteo, kein API-Key noetig)
// ===============================================================================================================================
const float weatherLat = 49.76;
const float weatherLon = 6.64;
float currentTemp = NAN;
int currentWeatherCode = -1;
unsigned long lastWeatherFetch = 0;
bool weatherFetchedOnce = false;
const unsigned long WEATHER_INTERVAL = 15UL * 60UL * 1000UL;
// ===============================================================================================================================
// ZUSTANDS TRACKING
// - gaStates: Schalter (0/1) und Byte Werte (0-255, Position/Helligkeit)
// - gaFloatStates: Gleitkomma Sensorwerte (DPT9/DPT14) aus Wetterstation/Energie/Raumtemperatur
// ===============================================================================================================================
#define STATE_MAP_SIZE 24
struct GaState { uint16_t ga; uint8_t value; bool known; };
GaState gaStates[STATE_MAP_SIZE];
#define SENSOR_MAP_SIZE 32
struct GaFloatState { uint16_t ga; float value; bool known; };
GaFloatState gaFloatStates[SENSOR_MAP_SIZE];
// ===============================================================================================================================
// TELEGRAMM VERLAUF (nur im Web UI sichtbar)
// ===============================================================================================================================
#define KNX_LOG_SIZE 10
struct KnxLogEntry {
char timeStr[24];
uint16_t ga;
uint8_t data[16];
uint8_t len;
};
KnxLogEntry knxLog[KNX_LOG_SIZE];
int knxLogNext = 0;
int knxLogFilled = 0;
// ===============================================================================================================================
// ZEIT
// ===============================================================================================================================
String getFormattedDateTime() {
struct tm timeinfo;
if (!getLocalTime(&timeinfo, 100)) return "--.--.---- - --:--:--";
char buf[32];
strftime(buf, sizeof(buf), "%d.%m.%Y - %H:%M:%S", &timeinfo);
return String(buf);
}
// ===============================================================================================================================
// GRUPPENADRESSEN HELFER
// ===============================================================================================================================
uint16_t parseGroupAddress(const String& gaStr) {
int firstSlash = gaStr.indexOf('/');
int secondSlash = gaStr.indexOf('/', firstSlash + 1);
if (firstSlash == -1 || secondSlash == -1) return 0;
uint8_t main = gaStr.substring(0, firstSlash).toInt();
uint8_t middle = gaStr.substring(firstSlash + 1, secondSlash).toInt();
uint8_t sub = gaStr.substring(secondSlash + 1).toInt();
return ((main & 0x1F) << 11) | ((middle & 0x07) << 8) | (sub & 0xFF);
}
String gaToString(uint16_t ga) {
uint8_t main = (ga >> 11) & 0x1F;
uint8_t middle = (ga >> 8) & 0x07;
uint8_t sub = ga & 0xFF;
return String(main) + "/" + String(middle) + "/" + String(sub);
}
// ===============================================================================================================================
// DPT 9 / DPT 14 (Gleitkomma)
// ===============================================================================================================================
uint16_t floatToDpt9(float val) {
int sign = (val < 0) ? 1 : 0;
float v = (sign == 1) ? -val : val;
int exp = 0;
int mantissa = (int)(v * 100.0f);
while (mantissa > 2047) { mantissa >>= 1; exp++; }
if (sign == 1) { mantissa = -mantissa; mantissa &= 0x07FF; }
return (sign << 15) | ((exp & 0x0F) << 11) | (mantissa & 0x07FF);
}
float dpt9ToFloat(uint8_t hi, uint8_t lo) {
uint16_t raw = ((uint16_t)hi << 8) | lo;
int sign = (raw >> 15) & 0x01;
int exp = (raw >> 11) & 0x0F;
int mantissa11 = raw & 0x07FF;
int M = mantissa11 - (sign ? 2048 : 0);
return 0.01f * M * (float)(1 << exp);
}
void floatToDpt14Bytes(float val, uint8_t* out4) {
uint8_t* p = (uint8_t*)&val;
out4[0] = p[3]; out4[1] = p[2]; out4[2] = p[1]; out4[3] = p[0];
}
float dpt14ToFloat(uint8_t b0, uint8_t b1, uint8_t b2, uint8_t b3) {
uint8_t bytes[4] = { b3, b2, b1, b0 };
float f; memcpy(&f, bytes, 4);
return f;
}
String apciTypeStr(uint8_t apciByte) {
switch (apciByte & 0xC0) {
case 0x00: return "Read";
case 0x40: return "Response";
case 0x80: return "Write";
default: return "?";
}
}
// ===============================================================================================================================
// Interpretiert Nutzdaten anhand der Bytelaenge (nur fuer Web Verlauf)
// ===============================================================================================================================
String interpretKnxData(uint8_t* data, uint8_t len) {
String prefix = "[" + apciTypeStr(data[0]) + "] ";
if (len == 1) {
uint8_t raw = data[0] & 0x0F;
return prefix + "1/2/4-Bit: 0x" + String(raw, HEX) + " (DPT1/2/3)";
}
else if (len == 2) {
char c = (data[1] >= 32 && data[1] < 127) ? (char)data[1] : '.';
return prefix + "DPT5: " + String(data[1]) + " / DPT4: '" + String(c) + "'";
}
else if (len == 3) {
return prefix + "DPT9: " + String(dpt9ToFloat(data[1], data[2]), 2);
}
else if (len == 4) {
uint8_t h = data[1] & 0x1F, mnt = data[2] & 0x3F, s = data[3] & 0x3F;
uint8_t day = data[1] & 0x1F, month = data[2] & 0x0F, year = data[3] & 0x7F;
char buf[80];
snprintf(buf, sizeof(buf), "DPT10 %02d:%02d:%02d / DPT11 %02d.%02d.20%02d", h, mnt, s, day, month, year);
return prefix + String(buf);
}
else if (len == 5) {
return prefix + "DPT14: " + String(dpt14ToFloat(data[1], data[2], data[3], data[4]), 3);
}
else if (len == 15) {
char txt[15];
for (int i = 0; i < 14; i++) {
uint8_t c = data[1 + i];
txt[i] = (c >= 32 && c < 127) ? (char)c : (c == 0 ? 0 : '.');
}
txt[14] = 0;
return prefix + "DPT16: \"" + String(txt) + "\"";
}
return prefix + "Unbekannt (" + String(len) + " Byte)";
}
// ===============================================================================================================================
// ZUSTANDS TRACKING
// ===============================================================================================================================
void updateGaState(uint16_t ga, uint8_t val) {
for (int i = 0; i < STATE_MAP_SIZE; i++) {
if (gaStates[i].known && gaStates[i].ga == ga) { gaStates[i].value = val; return; }
}
for (int i = 0; i < STATE_MAP_SIZE; i++) {
if (!gaStates[i].known) { gaStates[i].ga = ga; gaStates[i].value = val; gaStates[i].known = true; return; }
}
}
void updateGaFloatState(uint16_t ga, float val) {
for (int i = 0; i < SENSOR_MAP_SIZE; i++) {
if (gaFloatStates[i].known && gaFloatStates[i].ga == ga) { gaFloatStates[i].value = val; return; }
}
for (int i = 0; i < SENSOR_MAP_SIZE; i++) {
if (!gaFloatStates[i].known) { gaFloatStates[i].ga = ga; gaFloatStates[i].value = val; gaFloatStates[i].known = true; return; }
}
}
// ===============================================================================================================================
// TELEGRAMM VERLAUF
// ===============================================================================================================================
void addKnxLogEntry(uint16_t ga, uint8_t* rxBuf, uint8_t npduLen) {
KnxLogEntry &e = knxLog[knxLogNext];
String t = getFormattedDateTime();
strncpy(e.timeStr, t.c_str(), sizeof(e.timeStr) - 1);
e.timeStr[sizeof(e.timeStr) - 1] = 0;
e.ga = ga;
uint8_t copyLen = npduLen;
if (copyLen > sizeof(e.data)) copyLen = sizeof(e.data);
memcpy(e.data, &rxBuf[20], copyLen);
e.len = copyLen;
knxLogNext = (knxLogNext + 1) % KNX_LOG_SIZE;
if (knxLogFilled < KNX_LOG_SIZE) knxLogFilled++;
}
// ===============================================================================================================================
// EINGEHENDE KNXNET/IP PAKETE (komplett still, nur intern)
// ===============================================================================================================================
uint16_t processIncomingPacket(uint8_t* rxBuf, int len) {
if (len < 6) return 0;
uint16_t serviceType = (rxBuf[2] << 8) | rxBuf[3];
switch (serviceType) {
case 0x0420: {
if (len >= 21) {
uint8_t rxChannel = rxBuf[7];
uint8_t rxSeqNum = rxBuf[8];
uint16_t ga = ((uint16_t)rxBuf[16] << 8) | rxBuf[17];
uint8_t npduLen = rxBuf[18];
if (rxChannel == knxChannelId) {
addKnxLogEntry(ga, rxBuf, npduLen);
if (npduLen == 1) updateGaState(ga, rxBuf[20] & 0x01);
else if (npduLen == 2) updateGaState(ga, rxBuf[21]);
else if (npduLen == 3 && len >= 23) updateGaFloatState(ga, dpt9ToFloat(rxBuf[21], rxBuf[22])); // DPT9 Sensor
else if (npduLen == 5 && len >= 25) updateGaFloatState(ga, dpt14ToFloat(rxBuf[21], rxBuf[22], rxBuf[23], rxBuf[24]));
uint8_t ackBuf[10] = { 0x06, 0x10, 0x04, 0x21, 0x00, 0x0A, 0x04, knxChannelId, rxSeqNum, 0x00 };
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(ackBuf, sizeof(ackBuf));
udp.endPacket();
}
}
break;
}
case 0x0208:
if (len >= 8 && rxBuf[7] != 0x00) isKnxConnected = false;
break;
case 0x0209: {
if (len >= 8) {
uint8_t rxChannel = rxBuf[6];
if (rxChannel == knxChannelId) {
uint8_t discResp[8] = { 0x06, 0x10, 0x02, 0x0A, 0x00, 0x08, knxChannelId, 0x00 };
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(discResp, sizeof(discResp));
udp.endPacket();
isKnxConnected = false;
}
}
break;
}
default:
break;
}
return serviceType;
}
// ===============================================================================================================================
// VERBINDUNGSAUFBAU / HEARTBEAT / TRENNEN
// ===============================================================================================================================
bool connectKnx(bool verbose = false) {
IPAddress myIp = WiFi.localIP();
if (verbose) {
Serial.println("[KNX] Sende CONNECT_REQUEST an Gateway...");
Serial.printf(" Lokale IP/Port die wir dem Gateway melden: %s:%d\n", myIp.toString().c_str(), localPort);
Serial.printf(" Ziel-Gateway: %s:%d\n", knxGatewayIp, knxPort);
}
uint8_t connReq[] = {
0x06, 0x10, 0x02, 0x05, 0x00, 0x1A,
0x08, 0x01, myIp[0], myIp[1], myIp[2], myIp[3], (uint8_t)(localPort >> 8), (uint8_t)(localPort & 0xFF),
0x08, 0x01, myIp[0], myIp[1], myIp[2], myIp[3], (uint8_t)(localPort >> 8), (uint8_t)(localPort & 0xFF),
0x04, 0x04, 0x02, 0x00
};
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(connReq, sizeof(connReq));
udp.endPacket();
unsigned long start = millis();
while (millis() - start < 2000) {
int size = udp.parsePacket();
if (size >= 8) {
uint8_t resp[32];
udp.read(resp, sizeof(resp));
if (resp[2] == 0x02 && resp[3] == 0x06) {
if (resp[7] == 0x00) {
knxChannelId = resp[6];
txSeqNum = 0;
isKnxConnected = true;
lastHeartbeat = millis();
if (verbose) Serial.printf("ERFOLGREICH! Channel ID: %d\n", knxChannelId);
return true;
} else {
if (verbose) Serial.printf("FEHLER! Status Code: 0x%02X\n", resp[7]);
return false;
}
}
}
delay(10);
}
if (verbose) Serial.println("TIMEOUT! Keine CONNECT_RESPONSE vom Gateway erhalten.");
isKnxConnected = false;
return false;
}
void sendConnectionStateRequest() {
IPAddress myIp = WiFi.localIP();
uint8_t req[16] = {
0x06, 0x10, 0x02, 0x07, 0x00, 0x10, knxChannelId, 0x00,
0x08, 0x01, myIp[0], myIp[1], myIp[2], myIp[3], (uint8_t)(localPort >> 8), (uint8_t)(localPort & 0xFF)
};
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(req, sizeof(req));
udp.endPacket();
}
void disconnectKnx() {
if (!isKnxConnected) return;
IPAddress myIp = WiFi.localIP();
uint8_t req[16] = {
0x06, 0x10, 0x02, 0x09, 0x00, 0x10, knxChannelId, 0x00,
0x08, 0x01, myIp[0], myIp[1], myIp[2], myIp[3], (uint8_t)(localPort >> 8), (uint8_t)(localPort & 0xFF)
};
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(req, sizeof(req));
udp.endPacket();
isKnxConnected = false;
}
void handleKnxRx() {
int packetSize = udp.parsePacket();
if (packetSize <= 0) return;
uint8_t rxBuf[64];
int len = udp.read(rxBuf, sizeof(rxBuf));
processIncomingPacket(rxBuf, len);
}
bool waitForAck(uint8_t seq) {
unsigned long waitStart = millis();
while (millis() - waitStart < ACK_TIMEOUT) {
int packetSize = udp.parsePacket();
if (packetSize > 0) {
uint8_t rxBuf[64];
int len = udp.read(rxBuf, sizeof(rxBuf));
uint16_t st = processIncomingPacket(rxBuf, len);
if (st == 0x0421 && len >= 10 && rxBuf[8] == seq) return rxBuf[9] == 0x00;
}
delay(5);
}
return false;
}
// ===============================================================================================================================
// SENDEN: GroupValueWrite (DPT1,2,3,4,5,9,10,11,14,16)
// ===============================================================================================================================
bool sendKnxValue(String groupAddress, String dptType, String rawValue) {
if (!isKnxConnected) { if (!connectKnx()) return false; }
uint16_t ga = parseGroupAddress(groupAddress);
uint8_t buf[64];
uint8_t payloadLen = 0;
buf[0] = 0x06; buf[1] = 0x10; buf[2] = 0x04; buf[3] = 0x20; buf[4] = 0x00; buf[5] = 0x00;
buf[6] = 0x04; buf[7] = knxChannelId; buf[8] = txSeqNum; buf[9] = 0x00;
buf[10] = 0x11; buf[11] = 0x00; buf[12] = 0xBC; buf[13] = 0xE0;
buf[14] = 0x00; buf[15] = 0x00;
buf[16] = (uint8_t)(ga >> 8);
buf[17] = (uint8_t)(ga & 0xFF);
if (dptType == "DPT1") {
buf[18] = 0x01; buf[19] = 0x00;
buf[20] = (rawValue.toInt() > 0) ? 0x81 : 0x80;
payloadLen = 21;
}
else if (dptType == "DPT2") {
buf[18] = 0x01; buf[19] = 0x00;
buf[20] = 0x80 | ((uint8_t)rawValue.toInt() & 0x03);
payloadLen = 21;
}
else if (dptType == "DPT3") {
int v = rawValue.toInt();
uint8_t dir = (v >= 0) ? 0x08 : 0x00;
uint8_t step = (uint8_t)abs(v) & 0x07;
buf[18] = 0x01; buf[19] = 0x00;
buf[20] = 0x80 | dir | step;
payloadLen = 21;
}
else if (dptType == "DPT4") {
buf[18] = 0x02; buf[19] = 0x00; buf[20] = 0x80;
buf[21] = (rawValue.length() > 0) ? (uint8_t)rawValue[0] : 0x00;
payloadLen = 22;
}
else if (dptType == "DPT5") {
buf[18] = 0x02; buf[19] = 0x00; buf[20] = 0x80;
buf[21] = (uint8_t)rawValue.toInt();
payloadLen = 22;
}
else if (dptType == "DPT9") {
uint16_t dpt9Val = floatToDpt9(rawValue.toFloat());
buf[18] = 0x03; buf[19] = 0x00; buf[20] = 0x80;
buf[21] = (uint8_t)(dpt9Val >> 8);
buf[22] = (uint8_t)(dpt9Val & 0xFF);
payloadLen = 23;
}
else if (dptType == "DPT10") {
int h = 0, m = 0, s = 0;
int c1 = rawValue.indexOf(':');
if (c1 == -1) { h = rawValue.toInt(); }
else {
h = rawValue.substring(0, c1).toInt();
int c2 = rawValue.indexOf(':', c1 + 1);
if (c2 == -1) m = rawValue.substring(c1 + 1).toInt();
else { m = rawValue.substring(c1 + 1, c2).toInt(); s = rawValue.substring(c2 + 1).toInt(); }
}
buf[18] = 0x04; buf[19] = 0x00; buf[20] = 0x80;
buf[21] = (uint8_t)(h & 0x1F);
buf[22] = (uint8_t)(m & 0x3F);
buf[23] = (uint8_t)(s & 0x3F);
payloadLen = 24;
}
else if (dptType == "DPT11") {
int day = 1, month = 1, year = 26;
int p1 = rawValue.indexOf('.');
if (p1 != -1) {
day = rawValue.substring(0, p1).toInt();
int p2 = rawValue.indexOf('.', p1 + 1);
if (p2 != -1) {
month = rawValue.substring(p1 + 1, p2).toInt();
int y = rawValue.substring(p2 + 1).toInt();
year = (y >= 2000) ? (y - 2000) : y;
}
} else { day = rawValue.toInt(); }
buf[18] = 0x04; buf[19] = 0x00; buf[20] = 0x80;
buf[21] = (uint8_t)(day & 0x1F);
buf[22] = (uint8_t)(month & 0x0F);
buf[23] = (uint8_t)(year & 0x7F);
payloadLen = 24;
}
else if (dptType == "DPT14") {
uint8_t db[4];
floatToDpt14Bytes(rawValue.toFloat(), db);
buf[18] = 0x05; buf[19] = 0x00; buf[20] = 0x80;
buf[21] = db[0]; buf[22] = db[1]; buf[23] = db[2]; buf[24] = db[3];
payloadLen = 25;
}
else if (dptType == "DPT16") {
uint8_t strLen = rawValue.length();
if (strLen > 14) strLen = 14;
buf[18] = 15; buf[19] = 0x00; buf[20] = 0x80;
for (int i = 0; i < 14; i++) buf[21 + i] = (i < strLen) ? rawValue[i] : 0x00;
payloadLen = 21 + 14;
}
else {
return false;
}
buf[5] = payloadLen;
uint8_t sentSeq = txSeqNum;
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(buf, payloadLen);
udp.endPacket();
txSeqNum++;
bool ackOk = waitForAck(sentSeq);
if (!ackOk) isKnxConnected = false;
return ackOk;
}
// ===============================================================================================================================
// SENDEN: GroupValueRead - komplett still
// ===============================================================================================================================
bool sendKnxRead(String groupAddress) {
if (groupAddress.length() == 0) return false;
if (!isKnxConnected) { if (!connectKnx()) return false; }
uint16_t ga = parseGroupAddress(groupAddress);
uint8_t buf[21];
buf[0] = 0x06; buf[1] = 0x10; buf[2] = 0x04; buf[3] = 0x20; buf[4] = 0x00; buf[5] = 21;
buf[6] = 0x04; buf[7] = knxChannelId; buf[8] = txSeqNum; buf[9] = 0x00;
buf[10] = 0x11; buf[11] = 0x00; buf[12] = 0xBC; buf[13] = 0xE0;
buf[14] = 0x00; buf[15] = 0x00;
buf[16] = (uint8_t)(ga >> 8);
buf[17] = (uint8_t)(ga & 0xFF);
buf[18] = 0x01; buf[19] = 0x00; buf[20] = 0x00;
uint8_t sentSeq = txSeqNum;
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(buf, sizeof(buf));
udp.endPacket();
txSeqNum++;
bool ackOk = waitForAck(sentSeq);
if (!ackOk) isKnxConnected = false;
return ackOk;
}
// ===============================================================================================================================
// Fire and forget Read: wartet NICHT auf das ACK. Die Antwort (GroupValueResponse)
// trudelt spaeter ganz normal ueber handleKnxRx()/processIncomingPacket() ein und
// fuellt gaStates/gaFloatStates im Hintergrund. Dadurch blockiert der Massen Abruf
// beim Start nicht mehr den Webserver Start.
// ===============================================================================================================================
void sendKnxReadAsync(String groupAddress) {
if (groupAddress.length() == 0) return;
if (!isKnxConnected) return;
uint16_t ga = parseGroupAddress(groupAddress);
uint8_t buf[21];
buf[0] = 0x06; buf[1] = 0x10; buf[2] = 0x04; buf[3] = 0x20; buf[4] = 0x00; buf[5] = 21;
buf[6] = 0x04; buf[7] = knxChannelId; buf[8] = txSeqNum; buf[9] = 0x00;
buf[10] = 0x11; buf[11] = 0x00; buf[12] = 0xBC; buf[13] = 0xE0;
buf[14] = 0x00; buf[15] = 0x00;
buf[16] = (uint8_t)(ga >> 8);
buf[17] = (uint8_t)(ga & 0xFF);
buf[18] = 0x01; buf[19] = 0x00; buf[20] = 0x00;
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(buf, sizeof(buf));
udp.endPacket();
txSeqNum++;
}
// ===============================================================================================================================
// INITIALER STATUS ABRUF (nicht blockierend, ueber loop() verteilt)
// Je EIN Read pro Aufruf, mit Wartezeit dazwischen, so bleibt der
// Webserver waehrend der ~4 Sekunden Anlaufzeit voll bedienbar.
// ===============================================================================================================================
int initialReadIndex = 0;
bool initialReadDone = false;
unsigned long nextInitialRead = 0;
const int initialReadFixedCount = 9;
const char* getInitialReadGa(int idx) {
const char* fixedGas[initialReadFixedCount] = {
kuecheDeckenlampe.gaRead, kuecheSpots.gaRead,
kuecheRolladenLinks.gaPosRead, kuecheRolladenRechts.gaPosRead,
esszimmerDeckeSwitch.gaRead, esszimmerDeckeDim.gaValRead,
wohnzimmerDeckeSwitch.gaRead, wohnzimmerDeckeDim.gaValRead,
wohnzimmerRolladen.gaPosRead
};
if (idx < initialReadFixedCount) return fixedGas[idx];
idx -= initialReadFixedCount;
if (idx < wetterSensorenCount) return wetterSensoren[idx].ga;
idx -= wetterSensorenCount;
if (idx < energieSensorenCount) return energieSensoren[idx].ga;
idx -= energieSensorenCount;
if (idx < raumTemperaturenCount) return raumTemperaturen[idx].ga;
idx -= raumTemperaturenCount;
if (idx < luftfeuchteSensorenCount) return luftfeuchteSensoren[idx].ga;
return "";
}
// ===============================================================================================================================
// Wird aus loop() aufgerufen, verarbeitet hoechstens einen Read pro Aufruf
// ===============================================================================================================================
void processInitialReadsStep() {
if (initialReadDone || !isKnxConnected) return;
if (millis() < nextInitialRead) return;
int total = initialReadFixedCount + wetterSensorenCount + energieSensorenCount + raumTemperaturenCount + luftfeuchteSensorenCount;
if (initialReadIndex >= total) { initialReadDone = true; return; }
sendKnxRead(getInitialReadGa(initialReadIndex));
initialReadIndex++;
nextInitialRead = millis() + 100;
}
// ===============================================================================================================================
// WETTER WIDGET
// ===============================================================================================================================
String weatherDescription(int code) {
if (code == 0) return "Klar";
if (code >= 1 && code <= 3) return "Bewoelkt";
if (code == 45 || code == 48) return "Nebel";
if (code >= 51 && code <= 67) return "Regen";
if (code >= 71 && code <= 77) return "Schnee";
if (code >= 80 && code <= 82) return "Schauer";
if (code >= 95) return "Gewitter";
return "--";
}
String weatherIcon(int code) {
if (code == 0) return "\u2600\uFE0F";
if (code >= 1 && code <= 3) return "\u26C5";
if (code == 45 || code == 48) return "\U0001F32B\uFE0F";
if (code >= 51 && code <= 67) return "\U0001F327\uFE0F";
if (code >= 71 && code <= 77) return "\u2744\uFE0F";
if (code >= 80 && code <= 82) return "\U0001F326\uFE0F";
if (code >= 95) return "\u26C8\uFE0F";
return "\U0001F321\uFE0F";
}
void updateWeather() {
WiFiClientSecure client;
client.setInsecure();
HTTPClient http;
String url = "https://api.open-meteo.com/v1/forecast?latitude=" + String(weatherLat, 2) +
"&longitude=" + String(weatherLon, 2) + "¤t_weather=true";
if (http.begin(client, url)) {
int code = http.GET();
if (code == 200) {
String payload = http.getString();
int cwIdx = payload.indexOf("\"current_weather\":{");
if (cwIdx != -1) {
String cw = payload.substring(cwIdx);
int tIdx = cw.indexOf("\"temperature\":");
if (tIdx != -1) currentTemp = cw.substring(tIdx + 14, cw.indexOf(',', tIdx)).toFloat();
int wIdx = cw.indexOf("\"weathercode\":");
if (wIdx != -1) {
int commaIdx = cw.indexOf(',', wIdx);
int braceIdx = cw.indexOf('}', wIdx);
int endIdx = (commaIdx == -1) ? braceIdx : (braceIdx == -1 ? commaIdx : min(commaIdx, braceIdx));
currentWeatherCode = cw.substring(wIdx + 14, endIdx).toInt();
}
}
}
http.end();
}
}
// ===============================================================================================================================
// Serial Befehle - einzige laufende Ausgabe nach dem Start
// ===============================================================================================================================
void handleSerialCommands() {
if (!Serial.available()) return;
String line = Serial.readStringUntil('\n');
line.trim();
if (line.length() == 0) return;
int sp1 = line.indexOf(' ');
String cmd = (sp1 == -1) ? line : line.substring(0, sp1);
String rest = (sp1 == -1) ? "" : line.substring(sp1 + 1);
cmd.toLowerCase();
rest.trim();
if (cmd == "senddpt") {
int p1 = rest.indexOf(' ');
int p2 = (p1 == -1) ? -1 : rest.indexOf(' ', p1 + 1);
if (p1 == -1 || p2 == -1) { Serial.println("Syntax: senddpt <ga> <dpt> <wert>"); return; }
String ga = rest.substring(0, p1);
String dpt = rest.substring(p1 + 1, p2);
String val = rest.substring(p2 + 1);
dpt.toUpperCase();
bool ok = sendKnxValue(ga, dpt, val);
Serial.println(ok ? "OK" : "FEHLER (kein ACK)");
}
else if (cmd == "readvalue") {
if (rest.length() == 0) { Serial.println("Syntax: readvalue <ga>"); return; }
uint16_t targetGa = parseGroupAddress(rest);
bool ok = sendKnxRead(rest);
if (!ok) { Serial.println("FEHLER (kein ACK)"); return; }
unsigned long waitStart = millis();
bool gotValue = false;
while (millis() - waitStart < 1000) {
int packetSize = udp.parsePacket();
if (packetSize > 0) {
uint8_t rxBuf[64];
int len = udp.read(rxBuf, sizeof(rxBuf));
uint16_t st = processIncomingPacket(rxBuf, len);
if (st == 0x0420 && len >= 21) {
uint16_t rxGa = ((uint16_t)rxBuf[16] << 8) | rxBuf[17];
uint8_t apciType = rxBuf[20] & 0xC0;
if (rxGa == targetGa && apciType == 0x40) {
Serial.println("OK - " + interpretKnxData(&rxBuf[20], rxBuf[18]));
gotValue = true;
break;
}
}
}
delay(5);
}
if (!gotValue) Serial.println("OK (gesendet), aber keine Antwort innerhalb 1s empfangen");
}
else if (cmd == "help" || cmd == "?") {
Serial.println("Befehle:");
Serial.println(" senddpt <ga> <dpt> <wert> z.B. senddpt 1/1/1 DPT1 1");
Serial.println(" readvalue <ga> z.B. readvalue 1/1/1");
Serial.println(" DPTs: DPT1,DPT2,DPT3,DPT4,DPT5,DPT9,DPT10,DPT11,DPT14,DPT16");
}
else {
Serial.println("Unbekannter Befehl - 'help' fuer Hilfe");
}
}
// ===============================================================================================================================
// JSON HELPER
// ===============================================================================================================================
String escapeJson(String s) {
String out;
for (size_t i = 0; i < s.length(); i++) {
char c = s[i];
if (c == '"' || c == '\\') out += '\\';
out += c;
}
return out;
}
// ===============================================================================================================================
// HTML BAUSTEINE
// ===============================================================================================================================
String htmlSwitchRow(const SwitchGA& sw) {
String s = "<div class='ctrl-row'><span class='ctrl-label'>" + String(sw.label) + "</span>";
s += "<div class='seg' data-ga='" + String(sw.gaRead) + "'>";
s += "<button class='seg-btn on' onclick=\"sendSwitch('" + String(sw.gaWrite) + "','" + String(sw.gaRead) + "',1)\">AN</button>";
s += "<button class='seg-btn off' onclick=\"sendSwitch('" + String(sw.gaWrite) + "','" + String(sw.gaRead) + "',0)\">AUS</button>";
s += "</div></div>";
return s;
}
String htmlSliderRow(const String& label, const String& gaWrite, const String& gaRead) {
String s = "<div class='ctrl-row slider-row'><span class='ctrl-label'>" + label + "</span></div>";
s += "<div class='slider-wrap'>";
s += "<input type='range' min='0' max='100' value='0' class='pct-slider' data-ga-write='" + gaWrite + "' data-ga-read='" + gaRead + "'>";
s += "<span class='slider-val'>--%</span>";
s += "</div>";
return s;
}
String htmlShutterRow(const ShutterGA& sh) {
String s = "<div class='ctrl-row'><span class='ctrl-label'>" + String(sh.label) + "</span>";
s += "<div class='arrow-group'>";
s += "<button class='round-btn' onclick=\"sendRaw('" + String(sh.gaMove) + "','DPT1',0)\">▲</button>";
s += "<button class='round-btn' onclick=\"sendRaw('" + String(sh.gaMove) + "','DPT1',1)\">▼</button>";
s += "</div></div>";
if (String(sh.gaPosWrite).length() > 0) {
s += htmlSliderRow("Position", sh.gaPosWrite, sh.gaPosRead);
}
return s;
}
String htmlDimBlock(const DimGA& d) {
String s = htmlSliderRow(d.label, d.gaValWrite, d.gaValRead);
s += "<div class='ctrl-row'><span class='ctrl-label'>Feinjustierung</span><div class='arrow-group'>";
s += "<button class='round-btn' onclick=\"sendRaw('" + String(d.gaRelWrite) + "','DPT3',-3)\">−</button>";
s += "<button class='round-btn' onclick=\"sendRaw('" + String(d.gaRelWrite) + "','DPT3',3)\">+</button>";
s += "</div></div>";
return s;
}
String htmlSensorRow(const SensorGA& sn) {
String s = "<div class='ctrl-row'><span class='ctrl-label'>" + String(sn.label) + "</span>";
s += "<span class='sensor-val' data-ga='" + String(sn.ga) + "' data-unit='" + String(sn.unit) + "'>--</span>";
s += "</div>";
return s;
}
String roomCardOpen(const String& accent, const String& icon, const String& name) {
return "<div class='room-card' style='--accent:" + accent + ";'><div class='room-head'><span class='room-icon'>" + icon + "</span><h3>" + name + "</h3></div>";
}
// ===============================================================================================================================
// WEBSERVER
// ===============================================================================================================================
void handleRoot() {
String html = "<!DOCTYPE html><html lang='de'><head><meta charset='UTF-8'>";
html += "<meta name='viewport' content='width=device-width, initial-scale=1.0'>";
html += "<title>" + String(projectName) + "</title>";
html += "<link rel='preconnect' href='https://fonts.googleapis.com'>";
html += "<link href='https://fonts.googleapis.com/css2?family=Quicksand:wght@500;600;700&family=Nunito:wght@400;600;700&display=swap' rel='stylesheet'>";
html += "<style>";
html += ":root{--bg:#f3efe9;--card:#ffffff;--text:#33302c;--muted:#9a938a;--line:#ece6dd;}";
html += "*{box-sizing:border-box;}";
html += "body{margin:0;background:var(--bg);color:var(--text);font-family:'Nunito',-apple-system,sans-serif;padding:20px;}";
html += "h1,h2,h3{font-family:'Quicksand',sans-serif;margin:0;}";
html += ".hero{background:var(--card);border-radius:20px;padding:22px 26px;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:16px;box-shadow:0 4px 18px rgba(60,50,30,0.06);margin-bottom:20px;}";
html += ".hero .clock{font-size:34px;font-weight:700;line-height:1;}";
html += ".hero .date{color:var(--muted);font-size:13.5px;margin-top:4px;}";
html += ".pill{background:var(--bg);border-radius:30px;padding:10px 18px;font-size:14.5px;font-weight:600;white-space:nowrap;}";
html += ".grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:18px;align-items:start;}";
html += ".room-card{background:var(--card);border-radius:20px;padding:18px 20px 8px;box-shadow:0 4px 18px rgba(60,50,30,0.06);border-top:5px solid var(--accent);}";
html += ".room-head{display:flex;align-items:center;gap:10px;margin-bottom:8px;}";
html += ".room-icon{width:38px;height:38px;border-radius:50%;background:var(--accent);display:flex;align-items:center;justify-content:center;font-size:19px;flex-shrink:0;}";
html += ".room-head h3{font-size:17px;}";
html += ".ctrl-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--line);gap:10px;}";
html += ".ctrl-row:last-child{border-bottom:none;}";
html += ".ctrl-row.slider-row{padding-bottom:0;border-bottom:none;}";
html += ".ctrl-label{font-size:14px;color:var(--text);font-weight:600;}";
html += ".sensor-val{font-size:14px;font-weight:700;color:var(--accent);}";
html += ".seg{display:flex;border-radius:20px;background:var(--bg);padding:3px;gap:2px;}";
html += ".seg-btn{border:none;background:transparent;padding:6px 14px;border-radius:16px;font-family:'Nunito',sans-serif;font-weight:700;font-size:12.5px;color:var(--muted);cursor:pointer;}";
html += ".seg-btn.active{background:var(--accent);color:#fff;}";
html += ".arrow-group{display:flex;gap:6px;}";
html += ".round-btn{width:34px;height:34px;border-radius:50%;border:none;background:var(--bg);color:var(--text);font-size:15px;cursor:pointer;}";
html += ".round-btn:hover{background:var(--accent);color:#fff;}";
html += ".slider-wrap{display:flex;align-items:center;gap:10px;padding:0 0 12px;border-bottom:1px solid var(--line);}";
html += "input[type=range]{-webkit-appearance:none;flex:1;height:6px;border-radius:3px;background:var(--bg);outline:none;}";
html += "input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:20px;height:20px;border-radius:50%;background:var(--accent);cursor:pointer;border:3px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,0.25);}";
html += "input[type=range]::-moz-range-thumb{width:20px;height:20px;border-radius:50%;background:var(--accent);cursor:pointer;border:3px solid #fff;}";
html += ".slider-val{font-size:12.5px;color:var(--muted);font-weight:700;min-width:36px;text-align:right;}";
html += ".camera-card{max-width:280px;}";
html += ".camera-card img{width:100%;max-height:130px;object-fit:cover;border-radius:14px;display:block;background:#000;margin-top:6px;}";
html += "details{background:var(--card);border-radius:20px;padding:16px 20px;margin-top:20px;box-shadow:0 4px 18px rgba(60,50,30,0.06);}";
html += "summary{cursor:pointer;font-weight:700;font-family:'Quicksand',sans-serif;font-size:14.5px;}";
html += "input[type=text],select,button.full{width:100%;padding:10px;margin:8px 0;box-sizing:border-box;border:1px solid var(--line);border-radius:10px;font-size:14px;background:var(--bg);color:var(--text);font-family:'Nunito',sans-serif;}";
html += "button.full{background:#33302c;color:#fff;font-weight:700;cursor:pointer;border:none;}";
html += "#statusMsg{min-height:18px;color:#4f8a72;font-size:13px;}";
html += "table{width:100%;border-collapse:collapse;font-size:12.5px;margin-top:8px;}";
html += "th,td{border-bottom:1px solid var(--line);padding:6px 4px;text-align:left;color:var(--muted);}";
html += "th{color:var(--text);}";
html += "b.ok{color:#4f8a72;} b.err{color:#c0503b;}";
html += "</style></head><body>";
html += "<div class='hero'>";
html += "<div><div class='clock' id='clock'>--:--</div><div class='date' id='dateNow'>...</div></div>";
html += "<div class='pill' id='weatherBox'>...</div>";
html += "<div class='pill'>KNX: <span id='knxStatus'>...</span></div>";
html += "</div>";
html += "<div class='grid'>";
html += roomCardOpen("#e0763f", "🍳", "Kueche");
html += htmlSwitchRow(kuecheDeckenlampe);
html += htmlSwitchRow(kuecheSpots);
html += htmlShutterRow(kuecheRolladenLinks);
html += htmlShutterRow(kuecheRolladenRechts);
html += "</div>";
html += roomCardOpen("#4f8a72", "🍽️", "Esszimmer");
html += htmlSwitchRow(esszimmerDeckeSwitch);
html += htmlDimBlock(esszimmerDeckeDim);
html += "</div>";
html += roomCardOpen("#8a5a76", "🛋️", "Wohnzimmer");
html += htmlSwitchRow(wohnzimmerDeckeSwitch);
html += htmlDimBlock(wohnzimmerDeckeDim);
html += htmlShutterRow(wohnzimmerRolladen);
html += "</div>";
// Kamera (kompakte Kachel)
html += "<div class='room-card camera-card' style='--accent:#33302c;'>";
html += "<div class='room-head'><span class='room-icon'>📷</span><h3>Kamera</h3></div>";
html += "<img src='" + String(cameraStreamUrl) + "' alt='Kamera Stream'>";
html += "</div>";
// Wetterstation
html += roomCardOpen("#3f6e8a", "🌤️", "Wetterstation");
for (int i = 0; i < wetterSensorenCount; i++) html += htmlSensorRow(wetterSensoren[i]);
html += "</div>";
// Energie
html += roomCardOpen("#c98a2b", "⚡", "Energie");
for (int i = 0; i < energieSensorenCount; i++) html += htmlSensorRow(energieSensoren[i]);
html += "</div>";
// Raumtemperaturen
html += roomCardOpen("#6b7f8f", "🌡️", "Raumtemperaturen");
for (int i = 0; i < raumTemperaturenCount; i++) html += htmlSensorRow(raumTemperaturen[i]);
html += "</div>";
// Luftfeuchtigkeit
html += roomCardOpen("#4a90a4", "💧", "Luftfeuchtigkeit");
for (int i = 0; i < luftfeuchteSensorenCount; i++) html += htmlSensorRow(luftfeuchteSensoren[i]);
html += "</div>";
html += "</div>";
html += "<details><summary>Erweitert: Manueller Befehl & Telegrammverlauf</summary>";
html += "<label>Gruppenadresse</label><input type='text' id='mga' value='1/0/0'>";
html += "<label>DPT</label><select id='mdpt'>";
html += "<option value='DPT1'>DPT1 (1 Bit)</option>";
html += "<option value='DPT2'>DPT2 (2 Bit, C+V, 0..3)</option>";
html += "<option value='DPT3'>DPT3 (Dimmen, -7..7)</option>";
html += "<option value='DPT4'>DPT4 (1 Zeichen)</option>";
html += "<option value='DPT5'>DPT5 (1 Byte, 0..255)</option>";
html += "<option value='DPT9'>DPT9 (2 Byte Float)</option>";
html += "<option value='DPT10'>DPT10 (Zeit, 14:30:00)</option>";
html += "<option value='DPT11'>DPT11 (Datum, 24.12.26)</option>";
html += "<option value='DPT14'>DPT14 (4 Byte Float)</option>";
html += "<option value='DPT16'>DPT16 (Text)</option>";
html += "</select>";
html += "<label>Wert</label><input type='text' id='mval' value='1'>";
html += "<button class='full' onclick='sendManual()'>Senden</button>";
html += "<button class='full' style='background:#9a938a;' onclick=\"fetch('/reconnect').then(()=>updateStatus())\">KNX Reconnect</button>";
html += "<div id='statusMsg'></div>";
html += "<table><thead><tr><th>Zeit</th><th>GA</th><th>Bytes</th><th>Hex</th><th>Wert</th></tr></thead>";
html += "<tbody id='logBody'><tr><td colspan='5'>Lade...</td></tr></tbody></table>";
html += "</details>";
// JS
html += "<script>";
html += "function flash(t){ var el=document.getElementById('statusMsg'); if(el){el.innerText=t; clearTimeout(window._st); window._st=setTimeout(()=>el.innerText='',2500);} }";
html += "function sendRaw(ga,dpt,val,statusGa){";
html += " var url='/send?ga='+encodeURIComponent(ga)+'&dpt='+dpt+'&val='+encodeURIComponent(val);";
html += " if(statusGa) url+='&status='+encodeURIComponent(statusGa);";
html += " fetch(url).then(r=>r.text()).then(function(t){ flash(t); updateStatus(); }).catch(()=>{});";
html += "}";
html += "function sendSwitch(gaWrite,gaRead,val){ sendRaw(gaWrite,'DPT1',val,gaRead); }";
html += "function sendManual(){ sendRaw(document.getElementById('mga').value, document.getElementById('mdpt').value, document.getElementById('mval').value); }";
html += "document.querySelectorAll('.pct-slider').forEach(function(sl){";
html += " var label = sl.nextElementSibling;";
html += " sl.addEventListener('input', function(){ label.innerText = sl.value + '%'; sl.dataset.dragging = '1'; });";
html += " sl.addEventListener('change', function(){";
html += " sl.dataset.dragging = '';";
html += " var byteVal = Math.round(sl.value * 255 / 100);";
html += " sendRaw(sl.dataset.gaWrite, 'DPT5', byteVal, sl.dataset.gaRead);";
html += " });";
html += "});";
html += "function updateStatus(){";
html += " fetch('/status').then(r=>r.json()).then(function(d){";
html += " var parts = d.datetime.split(' - ');";
html += " document.getElementById('clock').innerText = parts[1] || '--:--';";
html += " document.getElementById('dateNow').innerText = parts[0] || '';";
html += " document.getElementById('knxStatus').innerHTML = d.connected ? \"<b class='ok'>Verbunden (Ch \"+d.channel+\")</b>\" : \"<b class='err'>Getrennt</b>\";";
html += " document.getElementById('weatherBox').innerText = d.weather + ' ' + (d.temp!==null ? d.temp+'\\u00B0C' : '--');";
html += " document.querySelectorAll('.seg[data-ga]').forEach(function(seg){";
html += " var v = d.states[seg.dataset.ga];";
html += " seg.querySelectorAll('.seg-btn').forEach(function(b){ b.classList.remove('active'); });";
html += " if (v == 1) seg.querySelector('.seg-btn.on').classList.add('active');";
html += " else if (v == 0) seg.querySelector('.seg-btn.off').classList.add('active');";
html += " });";
html += " document.querySelectorAll('.pct-slider').forEach(function(sl){";
html += " if (sl.dataset.dragging === '1') return;";
html += " var v = d.states[sl.dataset.gaRead];";
html += " if (v !== undefined) {";
html += " var pct = Math.round(v/255*100);";
html += " sl.value = pct;";
html += " sl.nextElementSibling.innerText = pct + '%';";
html += " }";
html += " });";
html += " document.querySelectorAll('.sensor-val[data-ga]').forEach(function(el){";
html += " var v = d.sensors[el.dataset.ga];";
html += " el.innerText = (v!==undefined) ? Number(v).toFixed(1) + ' ' + el.dataset.unit : '--';";
html += " });";
html += " var rows='';";
html += " d.log.forEach(function(e){ rows+='<tr><td>'+e.time+'</td><td>'+e.ga+'</td><td>'+e.len+'</td><td>'+e.hex+'</td><td>'+e.value+'</td></tr>'; });";
html += " document.getElementById('logBody').innerHTML = rows || '<tr><td colspan=5>Noch keine Telegramme</td></tr>';";
html += " }).catch(function(){});";
html += "}";
html += "setInterval(updateStatus, 1000); updateStatus();";
html += "</script>";
html += "</body></html>";
server.send(200, "text/html", html);
}
void handleSend() {
if (server.hasArg("ga") && server.hasArg("dpt") && server.hasArg("val")) {
bool ok = sendKnxValue(server.arg("ga"), server.arg("dpt"), server.arg("val"));
if (ok && server.hasArg("status")) {
sendKnxRead(server.arg("status"));
}
server.send(200, "text/plain", ok ? "OK" : "Kein ACK");
} else {
server.send(400, "text/plain", "Fehlende Parameter");
}
}
void handleReconnect() {
connectKnx();
server.send(200, "text/plain", "Reconnected");
}
void handleStatus() {
String json = "{";
json += "\"datetime\":\"" + escapeJson(getFormattedDateTime()) + "\",";
json += "\"connected\":" + String(isKnxConnected ? "true" : "false") + ",";
json += "\"channel\":" + String(knxChannelId) + ",";
json += "\"temp\":" + (isnan(currentTemp) ? String("null") : String(currentTemp, 1)) + ",";
json += "\"weather\":\"" + escapeJson(weatherIcon(currentWeatherCode) + " " + weatherDescription(currentWeatherCode)) + "\",";
json += "\"states\":{";
bool first = true;
for (int i = 0; i < STATE_MAP_SIZE; i++) {
if (gaStates[i].known) {
if (!first) json += ",";
json += "\"" + gaToString(gaStates[i].ga) + "\":" + String(gaStates[i].value);
first = false;
}
}
json += "},";
json += "\"sensors\":{";
bool firstSensor = true;
for (int i = 0; i < SENSOR_MAP_SIZE; i++) {
if (gaFloatStates[i].known) {
if (!firstSensor) json += ",";
json += "\"" + gaToString(gaFloatStates[i].ga) + "\":" + String(gaFloatStates[i].value, 2);
firstSensor = false;
}
}
json += "},";
json += "\"log\":[";
for (int i = 0; i < knxLogFilled; i++) {
int idx = (knxLogNext - 1 - i + KNX_LOG_SIZE) % KNX_LOG_SIZE;
KnxLogEntry &e = knxLog[idx];
String hex = "";
for (int b = 0; b < e.len; b++) {
char hb[4];
snprintf(hb, sizeof(hb), "%02X ", e.data[b]);
hex += hb;
}
hex.trim();
if (i > 0) json += ",";
json += "{\"time\":\"" + escapeJson(String(e.timeStr)) + "\",";
json += "\"ga\":\"" + escapeJson(gaToString(e.ga)) + "\",";
json += "\"len\":" + String(e.len) + ",";
json += "\"hex\":\"" + escapeJson(hex) + "\",";
json += "\"value\":\"" + escapeJson(interpretKnxData(e.data, e.len)) + "\"}";
}
json += "]}";
server.send(200, "application/json", json);
}
// ===============================================================================================================================
// SETUP & LOOP
// ===============================================================================================================================
void setup() {
Serial.begin(115200);
delay(300);
Serial.printf("\n=== %s ===\n", projectName);
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) { delay(400); Serial.print("."); }
Serial.println("\nWLAN Verbunden!");
Serial.print("IP-Adresse: "); Serial.println(WiFi.localIP());
Serial.print("RSSI: "); Serial.println(WiFi.RSSI());
configTzTime(tzInfo, ntpServer);
Serial.println("Warte auf Zeitsynchronisation (NTP)...");
struct tm ti;
if (getLocalTime(&ti, 5000)) {
Serial.println("Zeit synchronisiert: " + getFormattedDateTime());
} else {
Serial.println("Zeit konnte nicht synchronisiert werden.");
}
udp.begin(localPort);
connectKnx(true);
server.on("/", handleRoot);
server.on("/send", handleSend);
server.on("/reconnect", handleReconnect);
server.on("/status", handleStatus);
server.begin();
}
void loop() {
server.handleClient();
handleKnxRx();
handleSerialCommands();
processInitialReadsStep();
if (isKnxConnected && (millis() - lastHeartbeat > HEARTBEAT_INTERVAL)) {
sendConnectionStateRequest();
lastHeartbeat = millis();
}
if (!weatherFetchedOnce || (millis() - lastWeatherFetch > WEATHER_INTERVAL)) {
updateWeather();
lastWeatherFetch = millis();
weatherFetchedOnce = true;
}
}
09
Code: ESP32 KNX Stack - WS2812b Steuerung
Dieser Code vereint eine WS2812B LED Steuerung über eine Weboberfläche mit einer vollständigen KNX Integration.
Funktionsumfang:
- Weboberfläche: Ermöglicht die Auswahl von 16 verschiedenen LED Effekten sowie die stufenlose Einstellung von Helligkeit und Geschwindigkeit. Zudem werden alle eingehenden KNX Telegramme live im Webinterface angezeigt, und es können auch manuell Telegramme versendet werden.
- KNX Steuerung:
- Schalten (Ein/Aus)
- Effekt vor/zurück schalten (durch alle 16 Effekte)
- Helligkeit (DPT 5) 0-255 steuern
- Geschwindigkeit (DPT 5) 0-255 steuern
- Effekte direkt anwählen (DPT 5)
- RGB Farben setzen (DPT 232.600)
- Serielle Konsole: Alle Aktionen und Telegramme lassen sich zusätzlich über den Seriellen Monitor überwachen und steuern.
Der komplette Sketch in einer Datei: Beinhaltet das WLAN- und NTP Setup, den Verbindungsaufbau, ein Sende Handling mit ACK Überwachung sowie eine automatische Erkennung eingehender Telegramme
(unterstützt die Datentypen DPT 1, 5, 9, 10, 11, 14, 16, 232).
ESP32_KNX_WS2812b.ino
#include <WiFi.h>
#include <WiFi.h>
#include <WiFiUdp.h>
#include <WebServer.h>
#include <time.h>
#include <FastLED.h>
const char* projectName = "LumenBus";
const char* ssid = "WLAN SSID";
const char* password = "WLAN Password";
const char* ntpServer = "pool.ntp.org";
const char* tzInfo = "CET-1CEST,M3.5.0,M10.5.0/3";
const char* knxGatewayIp = "KNX IP Gateway";
const uint16_t knxPort = 3671;
const uint16_t localPort = 3671;
WiFiUDP udp;
WebServer server(80);
uint8_t knxChannelId = 0;
uint8_t txSeqNum = 0;
bool isKnxConnected = false;
unsigned long lastHeartbeat = 0;
const unsigned long HEARTBEAT_INTERVAL = 60000;
const unsigned long ACK_TIMEOUT = 1000;
#define KNX_LOG_SIZE 10
struct KnxLogEntry {
char timeStr[24];
uint16_t ga;
uint8_t data[16];
uint8_t len;
};
KnxLogEntry knxLog[KNX_LOG_SIZE];
int knxLogNext = 0;
int knxLogFilled = 0;
// ===============================================================================================================================
// WS2812B LED-STREIFEN KONFIGURATION
// ===============================================================================================================================
#define LED_PIN 5 // <-- Datenpin anpassen
#define NUM_LEDS 180 // <-- Anzahl LEDs anpassen
#define LED_TYPE WS2812B
#define COLOR_ORDER GRB
CRGB leds[NUM_LEDS];
bool ledOn = true;
uint8_t ledBrightness = 150; // 0-255
uint8_t ledSpeed = 128; // 0-255, beeinflusst Animationsgeschwindigkeit
CRGB ledColor = CRGB(0, 160, 255); // Farbe fuer Effekte, die eine feste Farbe nutzen
uint8_t currentEffect = 1; // Start Effekt (Index in effectNames/effects)
uint8_t gHue = 0; // rotierender Basis Farbton fuer mehrere Effekte
const uint8_t effectCount = 16;
const char* effectNames[effectCount] = {
"Statische Farbe", "Rainbow", "Rainbow Glitter", "Confetti", "Sinelon",
"BPM", "Juggle", "ColorLoop", "Fire", "Theater Chase",
"Theater Chase Rainbow", "Breathing", "Strobe", "Color Wipe", "Twinkle", "Comet"
};
// Gruppenadressen fuer die LED-Steuerung (bitte anpassen)
const char* gaLedOnOff = "1/0/1"; // DPT1 - 0=Aus, 1=An
const char* gaEffectStep = "3/0/0"; // DPT1 - 0=voriger Effekt, 1=naechster Effekt
const char* gaEffectSet = "3/0/2"; // DPT5 - Effekt direkt per Nummer setzen (0-15)
const char* gaColorRGB = "3/0/3"; // DPT232.600 - 3-Byte RGB Farbe
const char* gaBrightness = "3/0/4"; // DPT5 - Helligkeit 0-255
const char* gaSpeed = "3/0/5"; // DPT5 - Effekt-Geschwindigkeit 0-255
uint16_t gaLedOnOffParsed, gaEffectStepParsed, gaEffectSetParsed, gaColorRGBParsed, gaBrightnessParsed, gaSpeedParsed;
const char* knxStatusToString(uint8_t status) {
switch (status) {
case 0x00: return "E_NO_ERROR";
case 0x01: return "E_HOST_PROTOCOL_TYPE";
case 0x02: return "E_VERSION_NOT_SUPPORTED";
case 0x04: return "E_SEQUENCE_NUMBER (Sequenznummer falsch/ausser Sync)";
case 0x21: return "E_CONNECTION_ID (unbekannte Channel ID)";
case 0x22: return "E_CONNECTION_TYPE";
case 0x23: return "E_CONNECTION_OPTION";
case 0x24: return "E_NO_MORE_CONNECTIONS (Gateway voll)";
case 0x26: return "E_DATA_CONNECTION";
case 0x27: return "E_KNX_CONNECTION";
case 0x29: return "E_TUNNELING_LAYER (nicht unterstuetzt)";
default: return "Unbekannter Statuscode";
}
}
String getFormattedDateTime() {
struct tm timeinfo;
if (!getLocalTime(&timeinfo, 100)) {
return "Zeit nicht synchronisiert";
}
char buf[32];
strftime(buf, sizeof(buf), "%d.%m.%Y %H:%M:%S", &timeinfo);
return String(buf);
}
uint16_t parseGroupAddress(const String& gaStr) {
int firstSlash = gaStr.indexOf('/');
int secondSlash = gaStr.indexOf('/', firstSlash + 1);
if (firstSlash == -1 || secondSlash == -1) return 0;
uint8_t main = gaStr.substring(0, firstSlash).toInt();
uint8_t middle = gaStr.substring(firstSlash + 1, secondSlash).toInt();
uint8_t sub = gaStr.substring(secondSlash + 1).toInt();
return ((main & 0x1F) << 11) | ((middle & 0x07) << 8) | (sub & 0xFF);
}
String gaToString(uint16_t ga) {
uint8_t main = (ga >> 11) & 0x1F;
uint8_t middle = (ga >> 8) & 0x07;
uint8_t sub = ga & 0xFF;
return String(main) + "/" + String(middle) + "/" + String(sub);
}
uint16_t floatToDpt9(float val) {
int sign = (val < 0) ? 1 : 0;
float v = (sign == 1) ? -val : val;
int exp = 0;
int mantissa = (int)(v * 100.0f);
while (mantissa > 2047) {
mantissa >>= 1;
exp++;
}
if (sign == 1) {
mantissa = -mantissa;
mantissa &= 0x07FF;
}
return (sign << 15) | ((exp & 0x0F) << 11) | (mantissa & 0x07FF);
}
float dpt9ToFloat(uint8_t hi, uint8_t lo) {
uint16_t raw = ((uint16_t)hi << 8) | lo;
int sign = (raw >> 15) & 0x01;
int exp = (raw >> 11) & 0x0F;
int mantissa11 = raw & 0x07FF;
int M = mantissa11 - (sign ? 2048 : 0);
return 0.01f * M * (float)(1 << exp);
}
void floatToDpt14Bytes(float val, uint8_t* out4) {
uint8_t* p = (uint8_t*)&val;
out4[0] = p[3];
out4[1] = p[2];
out4[2] = p[1];
out4[3] = p[0];
}
float dpt14ToFloat(uint8_t b0, uint8_t b1, uint8_t b2, uint8_t b3) {
uint8_t bytes[4] = { b3, b2, b1, b0 };
float f;
memcpy(&f, bytes, 4);
return f;
}
String apciTypeStr(uint8_t apciByte) {
switch (apciByte & 0xC0) {
case 0x00: return "Read";
case 0x40: return "Response";
case 0x80: return "Write";
default: return "?";
}
}
String interpretKnxData(uint8_t* data, uint8_t len) {
String prefix = "[" + apciTypeStr(data[0]) + "] ";
if (len == 1) {
uint8_t raw = data[0] & 0x0F;
return prefix + "1/2/4-Bit: 0x" + String(raw, HEX) + " (DPT1/2/3)";
}
else if (len == 2) {
return prefix + "DPT5: " + String(data[1]) + " (0..255)";
}
else if (len == 3) {
float f = dpt9ToFloat(data[1], data[2]);
return prefix + "DPT9: " + String(f, 2);
}
else if (len == 4) {
uint8_t h = data[1] & 0x1F, mnt = data[2] & 0x3F, s = data[3] & 0x3F;
uint8_t day = data[1] & 0x1F, month = data[2] & 0x0F, year = data[3] & 0x7F;
char buf[110];
snprintf(buf, sizeof(buf), "DPT10 Zeit: %02d:%02d:%02d / DPT11 Datum: %02d.%02d.20%02d / DPT232 RGB: #%02X%02X%02X",
h, mnt, s, day, month, year, data[1], data[2], data[3]);
return prefix + String(buf);
}
else if (len == 5) {
float f = dpt14ToFloat(data[1], data[2], data[3], data[4]);
return prefix + "DPT14: " + String(f, 3);
}
else if (len == 15) {
char txt[15];
for (int i = 0; i < 14; i++) {
uint8_t c = data[1 + i];
txt[i] = (c >= 32 && c < 127) ? (char)c : (c == 0 ? 0 : '.');
}
txt[14] = 0;
return prefix + "DPT16: \"" + String(txt) + "\"";
}
return prefix + "Unbekannt (" + String(len) + " Byte)";
}
void addKnxLogEntry(uint16_t ga, uint8_t* rxBuf, uint8_t npduLen) {
KnxLogEntry &e = knxLog[knxLogNext];
String t = getFormattedDateTime();
strncpy(e.timeStr, t.c_str(), sizeof(e.timeStr) - 1);
e.timeStr[sizeof(e.timeStr) - 1] = 0;
e.ga = ga;
uint8_t copyLen = npduLen;
if (copyLen > sizeof(e.data)) copyLen = sizeof(e.data);
memcpy(e.data, &rxBuf[20], copyLen);
e.len = copyLen;
knxLogNext = (knxLogNext + 1) % KNX_LOG_SIZE;
if (knxLogFilled < KNX_LOG_SIZE) knxLogFilled++;
}
void applyLedTelegram(uint16_t ga, uint8_t* rxBuf, uint8_t npduLen) {
if (ga == gaLedOnOffParsed && npduLen == 1) {
ledOn = rxBuf[20] & 0x01;
}
else if (ga == gaEffectStepParsed && npduLen == 1) {
bool up = rxBuf[20] & 0x01;
currentEffect = up ? (currentEffect + 1) % effectCount : (currentEffect + effectCount - 1) % effectCount;
}
else if (ga == gaEffectSetParsed && npduLen == 2) {
uint8_t val = rxBuf[21];
if (val < effectCount) currentEffect = val;
}
else if (ga == gaColorRGBParsed && npduLen == 4) {
ledColor = CRGB(rxBuf[21], rxBuf[22], rxBuf[23]);
}
else if (ga == gaBrightnessParsed && npduLen == 2) {
ledBrightness = rxBuf[21];
}
else if (ga == gaSpeedParsed && npduLen == 2) {
ledSpeed = rxBuf[21];
}
}
uint16_t processIncomingPacket(uint8_t* rxBuf, int len) {
if (len < 6) return 0;
uint16_t serviceType = (rxBuf[2] << 8) | rxBuf[3];
switch (serviceType) {
case 0x0420:
if (len >= 21) {
uint8_t rxChannel = rxBuf[7];
uint8_t rxSeqNum = rxBuf[8];
uint16_t ga = ((uint16_t)rxBuf[16] << 8) | rxBuf[17];
uint8_t npduLen = rxBuf[18];
if (rxChannel == knxChannelId) {
addKnxLogEntry(ga, rxBuf, npduLen);
applyLedTelegram(ga, rxBuf, npduLen);
uint8_t ackBuf[10] = {
0x06, 0x10, 0x04, 0x21, 0x00, 0x0A, 0x04, knxChannelId, rxSeqNum, 0x00
};
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(ackBuf, sizeof(ackBuf));
udp.endPacket();
}
}
break;
case 0x0208:
if (len >= 8 && rxBuf[7] != 0x00) {
Serial.println("[KNX] Heartbeat fehlgeschlagen -> Tunnel getrennt");
isKnxConnected = false;
}
break;
case 0x0209:
if (len >= 8) {
uint8_t rxChannel = rxBuf[6];
if (rxChannel == knxChannelId) {
uint8_t discResp[8] = { 0x06, 0x10, 0x02, 0x0A, 0x00, 0x08, knxChannelId, 0x00 };
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(discResp, sizeof(discResp));
udp.endPacket();
Serial.println("[KNX] Vom Gateway getrennt");
isKnxConnected = false;
}
}
break;
default:
break;
}
return serviceType;
}
bool connectKnx(bool verbose = false) {
IPAddress myIp = WiFi.localIP();
if (verbose) {
Serial.println("[KNX] Sende CONNECT_REQUEST an Gateway...");
Serial.printf(" Lokale IP/Port die wir dem Gateway melden: %s:%d\n", myIp.toString().c_str(), localPort);
Serial.printf(" Ziel-Gateway: %s:%d\n", knxGatewayIp, knxPort);
}
uint8_t connReq[] = {
0x06, 0x10, 0x02, 0x05, 0x00, 0x1A,
0x08, 0x01, myIp[0], myIp[1], myIp[2], myIp[3], (uint8_t)(localPort >> 8), (uint8_t)(localPort & 0xFF),
0x08, 0x01, myIp[0], myIp[1], myIp[2], myIp[3], (uint8_t)(localPort >> 8), (uint8_t)(localPort & 0xFF),
0x04, 0x04, 0x02, 0x00
};
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(connReq, sizeof(connReq));
udp.endPacket();
unsigned long start = millis();
while (millis() - start < 2000) {
int size = udp.parsePacket();
if (size >= 8) {
uint8_t resp[32];
int rlen = udp.read(resp, sizeof(resp));
if (resp[2] == 0x02 && resp[3] == 0x06) {
if (resp[7] == 0x00) {
knxChannelId = resp[6];
txSeqNum = 0;
isKnxConnected = true;
lastHeartbeat = millis();
if (verbose) Serial.printf("[KNX] Verbunden (Channel %d)\n", knxChannelId);
return true;
} else {
if (verbose) Serial.printf("[KNX] Verbindung fehlgeschlagen: %s\n", knxStatusToString(resp[7]));
return false;
}
}
}
delay(10);
}
if (verbose) Serial.println("[KNX] Timeout beim Verbindungsaufbau");
isKnxConnected = false;
return false;
}
void sendConnectionStateRequest() {
IPAddress myIp = WiFi.localIP();
uint8_t req[16] = {
0x06, 0x10, 0x02, 0x07, 0x00, 0x10,
knxChannelId, 0x00,
0x08, 0x01, myIp[0], myIp[1], myIp[2], myIp[3], (uint8_t)(localPort >> 8), (uint8_t)(localPort & 0xFF)
};
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(req, sizeof(req));
udp.endPacket();
}
void disconnectKnx() {
if (!isKnxConnected) return;
IPAddress myIp = WiFi.localIP();
uint8_t req[16] = {
0x06, 0x10, 0x02, 0x09, 0x00, 0x10,
knxChannelId, 0x00,
0x08, 0x01, myIp[0], myIp[1], myIp[2], myIp[3], (uint8_t)(localPort >> 8), (uint8_t)(localPort & 0xFF)
};
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(req, sizeof(req));
udp.endPacket();
isKnxConnected = false;
}
void handleKnxRx() {
int packetSize = udp.parsePacket();
if (packetSize <= 0) return;
uint8_t rxBuf[64];
int len = udp.read(rxBuf, sizeof(rxBuf));
processIncomingPacket(rxBuf, len);
}
bool sendKnxValue(String groupAddress, String dptType, String rawValue) {
if (!isKnxConnected) {
if (!connectKnx()) return false;
}
uint16_t ga = parseGroupAddress(groupAddress);
uint8_t buf[64];
uint8_t payloadLen = 0;
buf[0] = 0x06; buf[1] = 0x10;
buf[2] = 0x04; buf[3] = 0x20;
buf[4] = 0x00; buf[5] = 0x00;
buf[6] = 0x04;
buf[7] = knxChannelId;
buf[8] = txSeqNum;
buf[9] = 0x00;
buf[10] = 0x11;
buf[11] = 0x00;
buf[12] = 0xBC;
buf[13] = 0xE0;
buf[14] = 0x00; buf[15] = 0x00;
buf[16] = (uint8_t)(ga >> 8);
buf[17] = (uint8_t)(ga & 0xFF);
if (dptType == "DPT1") {
buf[18] = 0x01;
buf[19] = 0x00;
buf[20] = (rawValue.toInt() > 0) ? 0x81 : 0x80;
payloadLen = 21;
}
else if (dptType == "DPT2") {
buf[18] = 0x01;
buf[19] = 0x00;
buf[20] = 0x80 | ((uint8_t)rawValue.toInt() & 0x03);
payloadLen = 21;
}
else if (dptType == "DPT3") {
int v = rawValue.toInt();
uint8_t dir = (v >= 0) ? 0x08 : 0x00;
uint8_t step = (uint8_t)abs(v) & 0x07;
buf[18] = 0x01;
buf[19] = 0x00;
buf[20] = 0x80 | dir | step;
payloadLen = 21;
}
else if (dptType == "DPT5") {
buf[18] = 0x02;
buf[19] = 0x00;
buf[20] = 0x80;
buf[21] = (uint8_t)rawValue.toInt();
payloadLen = 22;
}
else if (dptType == "DPT9") {
uint16_t dpt9Val = floatToDpt9(rawValue.toFloat());
buf[18] = 0x03;
buf[19] = 0x00;
buf[20] = 0x80;
buf[21] = (uint8_t)(dpt9Val >> 8);
buf[22] = (uint8_t)(dpt9Val & 0xFF);
payloadLen = 23;
}
else if (dptType == "DPT10") {
int h = 0, m = 0, s = 0;
int c1 = rawValue.indexOf(':');
if (c1 == -1) {
h = rawValue.toInt();
} else {
h = rawValue.substring(0, c1).toInt();
int c2 = rawValue.indexOf(':', c1 + 1);
if (c2 == -1) {
m = rawValue.substring(c1 + 1).toInt();
} else {
m = rawValue.substring(c1 + 1, c2).toInt();
s = rawValue.substring(c2 + 1).toInt();
}
}
buf[18] = 0x04;
buf[19] = 0x00;
buf[20] = 0x80;
buf[21] = (uint8_t)(h & 0x1F);
buf[22] = (uint8_t)(m & 0x3F);
buf[23] = (uint8_t)(s & 0x3F);
payloadLen = 24;
}
else if (dptType == "DPT11") {
int day = 1, month = 1, year = 26;
int p1 = rawValue.indexOf('.');
if (p1 != -1) {
day = rawValue.substring(0, p1).toInt();
int p2 = rawValue.indexOf('.', p1 + 1);
if (p2 != -1) {
month = rawValue.substring(p1 + 1, p2).toInt();
int y = rawValue.substring(p2 + 1).toInt();
year = (y >= 2000) ? (y - 2000) : y;
}
} else {
day = rawValue.toInt();
}
buf[18] = 0x04;
buf[19] = 0x00;
buf[20] = 0x80;
buf[21] = (uint8_t)(day & 0x1F);
buf[22] = (uint8_t)(month & 0x0F);
buf[23] = (uint8_t)(year & 0x7F);
payloadLen = 24;
}
else if (dptType == "DPT14") {
uint8_t db[4];
floatToDpt14Bytes(rawValue.toFloat(), db);
buf[18] = 0x05;
buf[19] = 0x00;
buf[20] = 0x80;
buf[21] = db[0];
buf[22] = db[1];
buf[23] = db[2];
buf[24] = db[3];
payloadLen = 25;
}
else if (dptType == "DPT16") {
uint8_t strLen = rawValue.length();
if (strLen > 14) strLen = 14;
buf[18] = 15;
buf[19] = 0x00;
buf[20] = 0x80;
for (int i = 0; i < 14; i++) {
buf[21 + i] = (i < strLen) ? rawValue[i] : 0x00;
}
payloadLen = 21 + 14;
}
else if (dptType == "DPT232") {
int c1 = rawValue.indexOf(',');
int c2 = (c1 == -1) ? -1 : rawValue.indexOf(',', c1 + 1);
int r = 0, g = 0, b = 0;
if (c1 != -1 && c2 != -1) {
r = rawValue.substring(0, c1).toInt();
g = rawValue.substring(c1 + 1, c2).toInt();
b = rawValue.substring(c2 + 1).toInt();
}
buf[18] = 0x04;
buf[19] = 0x00;
buf[20] = 0x80;
buf[21] = (uint8_t)r;
buf[22] = (uint8_t)g;
buf[23] = (uint8_t)b;
payloadLen = 24;
}
else {
Serial.println("[KNX TX] FEHLER: Unbekannter DPT-Typ!");
return false;
}
buf[5] = payloadLen;
uint8_t sentSeq = txSeqNum;
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(buf, payloadLen);
udp.endPacket();
txSeqNum++;
unsigned long waitStart = millis();
bool ackOk = false;
while (millis() - waitStart < ACK_TIMEOUT) {
int packetSize = udp.parsePacket();
if (packetSize > 0) {
uint8_t rxBuf[64];
int len = udp.read(rxBuf, sizeof(rxBuf));
uint16_t st = processIncomingPacket(rxBuf, len);
if (st == 0x0421 && len >= 9 && rxBuf[8] == sentSeq) {
ackOk = (len >= 10 && rxBuf[9] == 0x00);
break;
}
}
delay(5);
}
if (!ackOk) isKnxConnected = false;
return ackOk;
}
bool sendKnxRead(String groupAddress) {
if (!isKnxConnected) { if (!connectKnx()) return false; }
uint16_t ga = parseGroupAddress(groupAddress);
uint8_t buf[21];
buf[0] = 0x06; buf[1] = 0x10; buf[2] = 0x04; buf[3] = 0x20; buf[4] = 0x00; buf[5] = 21;
buf[6] = 0x04; buf[7] = knxChannelId; buf[8] = txSeqNum; buf[9] = 0x00;
buf[10] = 0x11; buf[11] = 0x00; buf[12] = 0xBC; buf[13] = 0xE0;
buf[14] = 0x00; buf[15] = 0x00;
buf[16] = (uint8_t)(ga >> 8);
buf[17] = (uint8_t)(ga & 0xFF);
buf[18] = 0x01; buf[19] = 0x00; buf[20] = 0x00;
uint8_t sentSeq = txSeqNum;
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(buf, sizeof(buf));
udp.endPacket();
txSeqNum++;
unsigned long waitStart = millis();
bool ackOk = false;
while (millis() - waitStart < ACK_TIMEOUT) {
int packetSize = udp.parsePacket();
if (packetSize > 0) {
uint8_t rxBuf[64];
int len = udp.read(rxBuf, sizeof(rxBuf));
uint16_t st = processIncomingPacket(rxBuf, len);
if (st == 0x0421 && len >= 10 && rxBuf[8] == sentSeq) { ackOk = (rxBuf[9] == 0x00); break; }
}
delay(5);
}
if (!ackOk) isKnxConnected = false;
return ackOk;
}
// ===============================================================================================================================
// SERIAL BEFEHLE
// Bilden alles ab, was auch die Gruppenadressen koennen -
// einzige laufende Ausgabe im Betrieb neben Verbindungsfehlern.
// ===============================================================================================================================
void handleSerialCommands() {
if (!Serial.available()) return;
String line = Serial.readStringUntil('\n');
line.trim();
if (line.length() == 0) return;
int sp1 = line.indexOf(' ');
String cmd = (sp1 == -1) ? line : line.substring(0, sp1);
String rest = (sp1 == -1) ? "" : line.substring(sp1 + 1);
cmd.toLowerCase();
rest.trim();
if (cmd == "senddpt") {
int p1 = rest.indexOf(' ');
int p2 = (p1 == -1) ? -1 : rest.indexOf(' ', p1 + 1);
if (p1 == -1 || p2 == -1) { Serial.println("Syntax: senddpt <ga> <dpt> <wert>"); return; }
String ga = rest.substring(0, p1);
String dpt = rest.substring(p1 + 1, p2);
String val = rest.substring(p2 + 1);
dpt.toUpperCase();
bool ok = sendKnxValue(ga, dpt, val);
Serial.println(ok ? "OK" : "FEHLER (kein ACK)");
}
else if (cmd == "readvalue") {
if (rest.length() == 0) { Serial.println("Syntax: readvalue <ga>"); return; }
uint16_t targetGa = parseGroupAddress(rest);
bool ok = sendKnxRead(rest);
if (!ok) { Serial.println("FEHLER (kein ACK)"); return; }
unsigned long waitStart = millis();
bool gotValue = false;
while (millis() - waitStart < 1000) {
int packetSize = udp.parsePacket();
if (packetSize > 0) {
uint8_t rxBuf[64];
int len = udp.read(rxBuf, sizeof(rxBuf));
uint16_t st = processIncomingPacket(rxBuf, len);
if (st == 0x0420 && len >= 21) {
uint16_t rxGa = ((uint16_t)rxBuf[16] << 8) | rxBuf[17];
uint8_t apciType = rxBuf[20] & 0xC0;
if (rxGa == targetGa && apciType == 0x40) {
Serial.println("OK - " + interpretKnxData(&rxBuf[20], rxBuf[18]));
gotValue = true;
break;
}
}
}
delay(5);
}
if (!gotValue) Serial.println("OK (gesendet), aber keine Antwort innerhalb 1s empfangen");
}
else if (cmd == "led") {
int p1 = rest.indexOf(' ');
String sub = (p1 == -1) ? rest : rest.substring(0, p1);
String arg = (p1 == -1) ? "" : rest.substring(p1 + 1);
sub.toLowerCase();
arg.trim();
if (sub == "on") { ledApplyOnOff(true); Serial.println("LED: AN"); }
else if (sub == "off") { ledApplyOnOff(false); Serial.println("LED: AUS"); }
else if (sub == "next") { ledApplyStep(true); Serial.println("LED Effekt: " + String(effectNames[currentEffect])); }
else if (sub == "prev") { ledApplyStep(false); Serial.println("LED Effekt: " + String(effectNames[currentEffect])); }
else if (sub == "effekt" || sub == "effect") {
int e = arg.toInt();
if (e < 0 || e >= effectCount) { Serial.println("Syntax: led effekt <0-" + String(effectCount - 1) + ">"); return; }
ledApplyEffect(e);
Serial.println("LED Effekt: " + String(effectNames[currentEffect]));
}
else if (sub == "farbe" || sub == "color") {
int c1 = arg.indexOf(','), c2 = (c1 == -1) ? -1 : arg.indexOf(',', c1 + 1);
if (c1 == -1 || c2 == -1) { Serial.println("Syntax: led farbe <r,g,b> z.B. led farbe 255,128,0"); return; }
ledApplyColor(arg.substring(0, c1).toInt(), arg.substring(c1 + 1, c2).toInt(), arg.substring(c2 + 1).toInt());
Serial.printf("LED Farbe: #%02X%02X%02X\n", ledColor.r, ledColor.g, ledColor.b);
}
else if (sub == "helligkeit" || sub == "bright") {
int b = arg.toInt();
if (arg.length() == 0) { Serial.println("Syntax: led helligkeit <0-255>"); return; }
ledApplyBrightness(b);
Serial.println("LED Helligkeit: " + String(ledBrightness));
}
else if (sub == "speed") {
int s = arg.toInt();
if (arg.length() == 0) { Serial.println("Syntax: led speed <0-255>"); return; }
ledApplySpeed(s);
Serial.println("LED Geschwindigkeit: " + String(ledSpeed));
}
else {
Serial.println("Unbekannter led-Befehl - 'help' fuer Hilfe");
}
}
else if (cmd == "help" || cmd == "?") {
Serial.println("Befehle:");
Serial.println(" senddpt <ga> <dpt> <wert> z.B. senddpt 1/1/1 DPT1 1");
Serial.println(" readvalue <ga> z.B. readvalue 1/1/1");
Serial.println(" led on / led off Strip an/aus");
Serial.println(" led next / led prev naechster/voriger Effekt");
Serial.println(" led effekt <0-" + String(effectCount - 1) + "> Effekt direkt waehlen");
Serial.println(" led farbe <r,g,b> z.B. led farbe 255,128,0");
Serial.println(" led helligkeit <0-255>");
Serial.println(" led speed <0-255>");
Serial.println(" DPTs: DPT1,DPT2,DPT3,DPT5,DPT9,DPT10,DPT11,DPT14,DPT16,DPT232");
}
else {
Serial.println("Unbekannter Befehl - 'help' fuer Hilfe");
}
}
// ===============================================================================================================================
// LED EFFEKTE
// ===============================================================================================================================
void effSolid() { fill_solid(leds, NUM_LEDS, ledColor); }
void effRainbow() { fill_rainbow(leds, NUM_LEDS, gHue, 7); }
void effRainbowGlitter() { effRainbow(); if (random8() < 80) leds[random16(NUM_LEDS)] += CRGB::White; }
void effConfetti() { fadeToBlackBy(leds, NUM_LEDS, 10); leds[random16(NUM_LEDS)] += CHSV(gHue + random8(64), 200, 255); }
void effSinelon() { fadeToBlackBy(leds, NUM_LEDS, 20); leds[beatsin16(13, 0, NUM_LEDS - 1)] += CHSV(gHue, 255, 192); }
void effBpm() {
CRGBPalette16 palette = PartyColors_p;
uint8_t beat = beatsin8(62, 64, 255);
for (int i = 0; i < NUM_LEDS; i++) leds[i] = ColorFromPalette(palette, gHue + (i * 2), beat - gHue + (i * 10));
}
void effJuggle() {
fadeToBlackBy(leds, NUM_LEDS, 20);
uint8_t dothue = 0;
for (int i = 0; i < 8; i++) { leds[beatsin16(i + 7, 0, NUM_LEDS - 1)] |= CHSV(dothue, 200, 255); dothue += 32; }
}
void effColorLoop() { fill_solid(leds, NUM_LEDS, CHSV(gHue, 255, 255)); }
void effFire() {
static byte heat[NUM_LEDS];
for (int i = 0; i < NUM_LEDS; i++) heat[i] = qsub8(heat[i], random8(0, ((55 * 10) / NUM_LEDS) + 2));
for (int k = NUM_LEDS - 1; k >= 2; k--) heat[k] = (heat[k - 1] + heat[k - 2] + heat[k - 2]) / 3;
if (random8() < 120) { int y = random8(7); heat[y] = qadd8(heat[y], random8(160, 255)); }
for (int j = 0; j < NUM_LEDS; j++) leds[j] = HeatColor(heat[j]);
}
void effTheaterChase() {
static uint8_t offset = 0;
fill_solid(leds, NUM_LEDS, CRGB::Black);
for (int i = offset; i < NUM_LEDS; i += 3) leds[i] = ledColor;
EVERY_N_MILLISECONDS(100) { offset = (offset + 1) % 3; }
}
void effTheaterChaseRainbow() {
static uint8_t offset = 0;
fill_solid(leds, NUM_LEDS, CRGB::Black);
for (int i = offset; i < NUM_LEDS; i += 3) leds[i] = CHSV(gHue + i * 4, 255, 255);
EVERY_N_MILLISECONDS(100) { offset = (offset + 1) % 3; }
}
void effBreathing() {
uint8_t b = beatsin8(15, 10, 255);
CRGB c = ledColor;
c.nscale8(b);
fill_solid(leds, NUM_LEDS, c);
}
void effStrobe() {
static bool on = false;
EVERY_N_MILLISECONDS(80) { on = !on; }
fill_solid(leds, NUM_LEDS, on ? ledColor : CRGB::Black);
}
void effColorWipe() {
static int pos = 0;
EVERY_N_MILLISECONDS(40) {
leds[pos] = ledColor;
pos = (pos + 1) % NUM_LEDS;
if (pos == 0) fill_solid(leds, NUM_LEDS, CRGB::Black);
}
}
void effTwinkle() {
fadeToBlackBy(leds, NUM_LEDS, 10);
if (random8() < 60) leds[random16(NUM_LEDS)] = ledColor;
}
void effComet() {
static int pos = 0;
fadeToBlackBy(leds, NUM_LEDS, 40);
leds[pos] = ledColor;
EVERY_N_MILLISECONDS(30) { pos = (pos + 1) % NUM_LEDS; }
}
typedef void (*EffectFunc)();
EffectFunc effects[effectCount] = {
effSolid, effRainbow, effRainbowGlitter, effConfetti, effSinelon,
effBpm, effJuggle, effColorLoop, effFire, effTheaterChase,
effTheaterChaseRainbow, effBreathing, effStrobe, effColorWipe, effTwinkle, effComet
};
void updateLeds() {
static unsigned long lastFrame = 0;
const unsigned long FRAME_INTERVAL = 20;
if (millis() - lastFrame < FRAME_INTERVAL) return;
lastFrame = millis();
if (!ledOn) {
fill_solid(leds, NUM_LEDS, CRGB::Black);
FastLED.show();
return;
}
static unsigned long lastHueStep = 0;
unsigned long hueInterval = map(ledSpeed, 0, 255, 120, 4);
if (millis() - lastHueStep > hueInterval) {
gHue++;
lastHueStep = millis();
}
effects[currentEffect]();
FastLED.setBrightness(ledBrightness);
FastLED.show();
}
String escapeJson(String s) {
String out;
for (size_t i = 0; i < s.length(); i++) {
char c = s[i];
if (c == '"' || c == '\\') out += '\\';
out += c;
}
return out;
}
void handleRoot() {
String html = "<!DOCTYPE html><html lang='de'><head><meta charset='UTF-8'>";
html += "<meta name='viewport' content='width=device-width, initial-scale=1.0'>";
html += "<title>" + String(projectName) + "</title>";
html += "<link rel='preconnect' href='https://fonts.googleapis.com'>";
html += "<link href='https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@500;600;700&family=Inter:wght@400;500;600;700&display=swap' rel='stylesheet'>";
html += "<style>";
html += ":root{--bg:#0a0b0f;--card:#14161d;--line:#242733;--text:#eef0f5;--muted:#8b90a0;}";
html += "*{box-sizing:border-box;}";
html += "body{margin:0;background:var(--bg);color:var(--text);font-family:'Inter',sans-serif;padding:22px;}";
html += "h1,h2,h3{font-family:'JetBrains Mono',monospace;margin:0;}";
html += ".title{font-size:26px;font-weight:700;background:linear-gradient(90deg,#ff5f6d,#ffc371,#47e08a,#4facfe,#c471f5,#ff5f6d);";
html += "background-size:300% auto;-webkit-background-clip:text;background-clip:text;color:transparent;animation:hueshift 6s linear infinite;}";
html += "@keyframes hueshift{to{background-position:300% center;}}";
html += "@media (prefers-reduced-motion:reduce){.title{animation:none;}}";
html += ".topbar{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:12px;margin-bottom:20px;}";
html += ".pill{background:var(--card);border:1px solid var(--line);border-radius:20px;padding:8px 16px;font-size:13.5px;color:var(--muted);}";
html += ".grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:18px;}";
html += ".card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:20px;}";
html += ".card h3{font-size:15px;color:var(--muted);letter-spacing:0.04em;text-transform:uppercase;margin-bottom:14px;}";
html += ".led-toggle{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;}";
html += ".onoff-btn{border:none;border-radius:24px;padding:10px 22px;font-family:'JetBrains Mono',monospace;font-weight:700;font-size:13px;cursor:pointer;background:#242733;color:var(--muted);}";
html += ".onoff-btn.on{background:linear-gradient(90deg,#4facfe,#47e08a);color:#0a0b0f;}";
html += ".effect-name{font-family:'JetBrains Mono',monospace;font-size:15px;text-align:center;margin:8px 0;color:#fff;}";
html += ".stepper{display:flex;align-items:center;justify-content:center;gap:14px;margin-bottom:16px;}";
html += ".round-btn{width:40px;height:40px;border-radius:50%;border:1px solid var(--line);background:#1c1f28;color:var(--text);font-size:18px;cursor:pointer;}";
html += ".round-btn:hover{border-color:#4facfe;color:#4facfe;}";
html += ".effect-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(90px,1fr));gap:8px;margin-top:6px;}";
html += ".effect-btn{background:#1c1f28;border:1px solid var(--line);color:var(--muted);border-radius:8px;padding:8px 6px;font-size:11.5px;cursor:pointer;text-align:center;}";
html += ".effect-btn.active{border-color:#c471f5;color:#fff;background:#241c2e;box-shadow:0 0 10px rgba(196,113,245,0.35);}";
html += ".color-row{display:flex;align-items:center;gap:14px;margin-bottom:16px;}";
html += "input[type=color]{width:52px;height:52px;border:none;border-radius:50%;background:none;cursor:pointer;padding:0;}";
html += ".swatch{width:52px;height:52px;border-radius:50%;box-shadow:0 0 18px 2px var(--swatch-glow,#4facfe);border:2px solid #fff2;}";
html += ".slider-label{display:flex;justify-content:space-between;font-size:12.5px;color:var(--muted);margin-bottom:4px;}";
html += "input[type=range]{-webkit-appearance:none;width:100%;height:6px;border-radius:3px;background:#242733;outline:none;margin:6px 0 16px;}";
html += "input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:18px;height:18px;border-radius:50%;background:linear-gradient(90deg,#4facfe,#c471f5);cursor:pointer;border:2px solid #fff;}";
html += "details{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:16px 20px;margin-top:20px;}";
html += "summary{cursor:pointer;font-weight:700;font-family:'JetBrains Mono',monospace;font-size:13.5px;color:var(--muted);}";
html += "input[type=text],select,button.full{width:100%;padding:10px;margin:8px 0;box-sizing:border-box;border:1px solid var(--line);border-radius:8px;font-size:14px;background:#10121a;color:var(--text);font-family:'Inter',sans-serif;}";
html += "button.full{background:#2d6fb3;color:#fff;font-weight:700;cursor:pointer;border:none;}";
html += "#statusMsg{min-height:18px;color:#47e08a;font-size:13px;}";
html += "table{width:100%;border-collapse:collapse;font-size:12.5px;margin-top:8px;}";
html += "th,td{border-bottom:1px solid var(--line);padding:6px 4px;text-align:left;color:var(--muted);}";
html += "th{color:var(--text);}";
html += "b.ok{color:#47e08a;} b.err{color:#ff5f6d;}";
html += "</style></head><body>";
html += "<div class='topbar'>";
html += "<div><span class='title'>" + String(projectName) + "</span></div>";
html += "<div class='pill' id='dtNow'>...</div>";
html += "<div class='pill'>KNX: <span id='knxStatus'>...</span></div>";
html += "</div>";
html += "<div class='grid'>";
html += "<div class='card' style='grid-column:1/-1;'>";
html += "<h3>WS2812B LED-Streifen</h3>";
html += "<div class='led-toggle'><span>Strip Ein/Aus</span><button class='onoff-btn' id='onoffBtn' onclick='toggleOnOff()'>...</button></div>";
html += "<div class='stepper'>";
html += "<button class='round-btn' onclick='ledStep(0)'>‹</button>";
html += "<span class='effect-name' id='effectName'>...</span>";
html += "<button class='round-btn' onclick='ledStep(1)'>›</button>";
html += "</div>";
html += "<div class='effect-grid' id='effectGrid'></div>";
html += "<div class='color-row' style='margin-top:18px;'>";
html += "<input type='color' id='colorPicker' value='#00a0ff' onchange='ledColorChange(this.value)'>";
html += "<div class='swatch' id='swatch'></div>";
html += "<span style='color:var(--muted);font-size:13px;'>Farbe fuer Statische Farbe / Theater Chase / Breathing / Strobe / Color Wipe / Twinkle / Comet</span>";
html += "</div>";
html += "<div class='slider-label'><span>Helligkeit</span><span id='brightVal'>--%</span></div>";
html += "<input type='range' min='0' max='255' value='150' id='brightSlider' oninput=\"document.getElementById('brightVal').innerText=Math.round(this.value/255*100)+'%'\" onchange='ledBrightChange(this.value)'>";
html += "<div class='slider-label'><span>Geschwindigkeit</span><span id='speedVal'>--%</span></div>";
html += "<input type='range' min='0' max='255' value='128' id='speedSlider' oninput=\"document.getElementById('speedVal').innerText=Math.round(this.value/255*100)+'%'\" onchange='ledSpeedChange(this.value)'>";
html += "</div>";
html += "</div>"; // .grid
html += "<details><summary>Erweitert: Manueller KNX-Befehl & Telegrammverlauf</summary>";
html += "<label>Gruppenadresse</label><input type='text' id='ga' value='1/0/0'>";
html += "<label>Datenpunkttyp (DPT)</label><select id='dpt'>";
html += "<option value='DPT1'>DPT 1 (1 Bit)</option>";
html += "<option value='DPT2'>DPT 2 (2 Bit, C+V, 0..3)</option>";
html += "<option value='DPT3'>DPT 3 (Dimmen, -7..7)</option>";
html += "<option value='DPT5'>DPT 5 (1 Byte 0..255)</option>";
html += "<option value='DPT9'>DPT 9 (2 Byte Float)</option>";
html += "<option value='DPT10'>DPT 10 (Zeit, 14:30:00)</option>";
html += "<option value='DPT11'>DPT 11 (Datum, 24.12.26)</option>";
html += "<option value='DPT14'>DPT 14 (4 Byte Float)</option>";
html += "<option value='DPT16'>DPT 16 (Text)</option>";
html += "<option value='DPT232'>DPT 232 (RGB, z.B. 255,128,0)</option>";
html += "</select>";
html += "<label>Wert / Payload</label><input type='text' id='val' value='1'>";
html += "<button class='full' onclick='sendTelegram()'>KNX Telegramm Senden</button>";
html += "<button class='full' style='background:#3a3f4d;' onclick=\"fetch('/reconnect').then(()=>updateStatus())\">KNX Reconnect</button>";
html += "<div id='statusMsg'></div>";
html += "<table><thead><tr><th>Zeit</th><th>GA</th><th>Bytes</th><th>Hex</th><th>Wert</th></tr></thead>";
html += "<tbody id='logBody'><tr><td colspan='5'>Lade...</td></tr></tbody></table>";
html += "</details>";
html += "<script>";
html += "var EFFECT_NAMES = [";
for (int i = 0; i < effectCount; i++) {
html += "\"" + String(effectNames[i]) + "\"";
if (i < effectCount - 1) html += ",";
}
html += "];";
html += "var grid = document.getElementById('effectGrid');";
html += "EFFECT_NAMES.forEach(function(name, i){";
html += " var b = document.createElement('button');";
html += " b.className='effect-btn'; b.innerText=name; b.onclick=function(){ ledSetEffect(i); };";
html += " grid.appendChild(b);";
html += "});";
html += "function flash(t){ var el=document.getElementById('statusMsg'); if(el){el.innerText=t; clearTimeout(window._st); window._st=setTimeout(()=>el.innerText='',2500);} }";
html += "function sendTelegram(){";
html += " var ga=document.getElementById('ga').value, dpt=document.getElementById('dpt').value, val=document.getElementById('val').value;";
html += " fetch('/send?ga='+encodeURIComponent(ga)+'&dpt='+encodeURIComponent(dpt)+'&val='+encodeURIComponent(val)).then(r=>r.text()).then(flash).catch(()=>{});";
html += "}";
html += "function ledCmd(qs){ fetch('/ledset?'+qs).then(r=>r.text()).then(()=>updateStatus()).catch(()=>{}); }";
html += "function toggleOnOff(){ var isOn = document.getElementById('onoffBtn').classList.contains('on'); ledCmd('onoff='+(isOn?0:1)); }";
html += "function ledStep(dir){ ledCmd('step='+dir); }";
html += "function ledSetEffect(i){ ledCmd('effect='+i); }";
html += "function ledColorChange(hex){";
html += " var r=parseInt(hex.substr(1,2),16), g=parseInt(hex.substr(3,2),16), b=parseInt(hex.substr(5,2),16);";
html += " document.getElementById('swatch').style.setProperty('--swatch-glow', hex);";
html += " document.getElementById('swatch').style.background = hex;";
html += " ledCmd('color='+r+','+g+','+b);";
html += "}";
html += "function ledBrightChange(v){ ledCmd('bright='+v); }";
html += "function ledSpeedChange(v){ ledCmd('speed='+v); }";
html += "function updateStatus(){";
html += " fetch('/status').then(r=>r.json()).then(function(d){";
html += " document.getElementById('dtNow').innerText = d.datetime;";
html += " document.getElementById('knxStatus').innerHTML = d.connected ? \"<b class='ok'>Verbunden (Ch \"+d.channel+\")</b>\" : \"<b class='err'>Getrennt</b>\";";
html += " var onoffBtn = document.getElementById('onoffBtn');";
html += " onoffBtn.classList.toggle('on', d.led.on);";
html += " onoffBtn.innerText = d.led.on ? 'AN' : 'AUS';";
html += " document.getElementById('effectName').innerText = d.led.effectName;";
html += " document.querySelectorAll('.effect-btn').forEach(function(b,i){ b.classList.toggle('active', i===d.led.effect); });";
html += " document.getElementById('colorPicker').value = d.led.color;";
html += " document.getElementById('swatch').style.background = d.led.color;";
html += " document.getElementById('swatch').style.setProperty('--swatch-glow', d.led.color);";
html += " document.getElementById('brightSlider').value = d.led.brightness;";
html += " document.getElementById('brightVal').innerText = Math.round(d.led.brightness/255*100)+'%';";
html += " document.getElementById('speedSlider').value = d.led.speed;";
html += " document.getElementById('speedVal').innerText = Math.round(d.led.speed/255*100)+'%';";
html += " var rows='';";
html += " d.log.forEach(function(e){ rows+='<tr><td>'+e.time+'</td><td>'+e.ga+'</td><td>'+e.len+'</td><td>'+e.hex+'</td><td>'+e.value+'</td></tr>'; });";
html += " document.getElementById('logBody').innerHTML = rows || '<tr><td colspan=5>Noch keine Telegramme empfangen</td></tr>';";
html += " }).catch(function(){});";
html += "}";
html += "setInterval(updateStatus, 1000); updateStatus();";
html += "</script>";
html += "</body></html>";
server.send(200, "text/html", html);
}
void handleSend() {
if (server.hasArg("ga") && server.hasArg("dpt") && server.hasArg("val")) {
String ga = server.arg("ga");
String dpt = server.arg("dpt");
String val = server.arg("val");
bool ok = sendKnxValue(ga, dpt, val);
server.send(200, "text/plain", ok ? "Telegramm gesendet und per ACK bestaetigt!" : "Gesendet, aber KEIN ACK erhalten - siehe Serial-Monitor!");
} else {
server.send(400, "text/plain", "Fehlende Parameter!");
}
}
void handleReconnect() {
connectKnx();
server.send(200, "text/plain", "Reconnected!");
}
void ledApplyOnOff(bool on) {
ledOn = on;
sendKnxValue(gaLedOnOff, "DPT1", ledOn ? "1" : "0");
}
void ledApplyStep(bool up) {
currentEffect = up ? (currentEffect + 1) % effectCount : (currentEffect + effectCount - 1) % effectCount;
sendKnxValue(gaEffectStep, "DPT1", up ? "1" : "0");
}
void ledApplyEffect(int e) {
if (e < 0 || e >= effectCount) return;
currentEffect = e;
sendKnxValue(gaEffectSet, "DPT5", String(e));
}
void ledApplyColor(int r, int g, int b) {
ledColor = CRGB(r, g, b);
sendKnxValue(gaColorRGB, "DPT232", String(r) + "," + String(g) + "," + String(b));
}
void ledApplyBrightness(int b) {
ledBrightness = (uint8_t)b;
sendKnxValue(gaBrightness, "DPT5", String(ledBrightness));
}
void ledApplySpeed(int s) {
ledSpeed = (uint8_t)s;
sendKnxValue(gaSpeed, "DPT5", String(ledSpeed));
}
void handleLedSet() {
if (server.hasArg("onoff")) ledApplyOnOff(server.arg("onoff").toInt() > 0);
if (server.hasArg("step")) ledApplyStep(server.arg("step").toInt() > 0);
if (server.hasArg("effect")) ledApplyEffect(server.arg("effect").toInt());
if (server.hasArg("color")) {
String c = server.arg("color"); // "r,g,b"
int c1 = c.indexOf(','), c2 = (c1 == -1) ? -1 : c.indexOf(',', c1 + 1);
if (c1 != -1 && c2 != -1) {
ledApplyColor(c.substring(0, c1).toInt(), c.substring(c1 + 1, c2).toInt(), c.substring(c2 + 1).toInt());
}
}
if (server.hasArg("bright")) ledApplyBrightness(server.arg("bright").toInt());
if (server.hasArg("speed")) ledApplySpeed(server.arg("speed").toInt());
server.send(200, "text/plain", "OK");
}
void handleStatus() {
String json = "{";
json += "\"project\":\"" + escapeJson(String(projectName)) + "\",";
json += "\"datetime\":\"" + escapeJson(getFormattedDateTime()) + "\",";
json += "\"connected\":" + String(isKnxConnected ? "true" : "false") + ",";
json += "\"channel\":" + String(knxChannelId) + ",";
char colHex[8];
snprintf(colHex, sizeof(colHex), "#%02X%02X%02X", ledColor.r, ledColor.g, ledColor.b);
json += "\"led\":{";
json += "\"on\":" + String(ledOn ? "true" : "false") + ",";
json += "\"effect\":" + String(currentEffect) + ",";
json += "\"effectName\":\"" + escapeJson(String(effectNames[currentEffect])) + "\",";
json += "\"color\":\"" + String(colHex) + "\",";
json += "\"brightness\":" + String(ledBrightness) + ",";
json += "\"speed\":" + String(ledSpeed);
json += "},";
json += "\"log\":[";
for (int i = 0; i < knxLogFilled; i++) {
int idx = (knxLogNext - 1 - i + KNX_LOG_SIZE) % KNX_LOG_SIZE;
KnxLogEntry &e = knxLog[idx];
String hex = "";
for (int b = 0; b < e.len; b++) {
char hb[4];
snprintf(hb, sizeof(hb), "%02X ", e.data[b]);
hex += hb;
}
hex.trim();
if (i > 0) json += ",";
json += "{";
json += "\"time\":\"" + escapeJson(String(e.timeStr)) + "\",";
json += "\"ga\":\"" + escapeJson(gaToString(e.ga)) + "\",";
json += "\"len\":" + String(e.len) + ",";
json += "\"hex\":\"" + escapeJson(hex) + "\",";
json += "\"value\":\"" + escapeJson(interpretKnxData(e.data, e.len)) + "\"";
json += "}";
}
json += "]}";
server.send(200, "application/json", json);
}
void setup() {
Serial.begin(115200);
delay(1000);
Serial.printf("\n===> %s <===\n", projectName);
FastLED.addLeds<LED_TYPE, LED_PIN, COLOR_ORDER>(leds, NUM_LEDS);
FastLED.setBrightness(ledBrightness);
fill_solid(leds, NUM_LEDS, CRGB::Black);
FastLED.show();
gaLedOnOffParsed = parseGroupAddress(gaLedOnOff);
gaEffectStepParsed = parseGroupAddress(gaEffectStep);
gaEffectSetParsed = parseGroupAddress(gaEffectSet);
gaColorRGBParsed = parseGroupAddress(gaColorRGB);
gaBrightnessParsed = parseGroupAddress(gaBrightness);
gaSpeedParsed = parseGroupAddress(gaSpeed);
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWLAN Verbunden!");
Serial.print("IP Adresse: "); Serial.println(WiFi.localIP());
Serial.print("RSSI: "); Serial.println(WiFi.RSSI());
configTzTime(tzInfo, ntpServer);
Serial.println("Warte auf Zeitsynchronisation (NTP)...");
struct tm timeinfo;
if (getLocalTime(&timeinfo, 5000)) {
Serial.println("Zeit synchronisiert: " + getFormattedDateTime());
} else {
Serial.println("Zeit konnte nicht synchronisiert werden (wird spaeter automatisch nachgeholt).");
}
udp.begin(localPort);
connectKnx(true);
server.on("/", handleRoot);
server.on("/send", handleSend);
server.on("/reconnect", handleReconnect);
server.on("/status", handleStatus);
server.on("/ledset", handleLedSet);
server.begin();
Serial.println("Bereit. 'help' fuer Serial-Befehle.");
}
void loop() {
server.handleClient();
handleKnxRx();
handleSerialCommands();
updateLeds();
if (isKnxConnected) {
if (millis() - lastHeartbeat > HEARTBEAT_INTERVAL) {
sendConnectionStateRequest();
lastHeartbeat = millis();
}
} else {
static unsigned long lastReconnectAttempt = 0;
if (millis() - lastReconnectAttempt > 3000) {
connectKnx();
lastReconnectAttempt = millis();
}
}
}
10
Code: ESP32 KNX Stack - RGB Steuerung
Dieser Code vereint eine RGB LED Steuerung über eine Weboberfläche mit einer vollständigen KNX Integration.
Funktionsumfang:
- Weboberfläche: Ermöglicht die Auswahl von 24 verschiedenen LED Effekten sowie die stufenlose Einstellung von Helligkeit und Geschwindigkeit. Zudem werden alle eingehenden KNX Telegramme live im Webinterface angezeigt, und es können auch manuell Telegramme versendet werden.
- KNX Steuerung:
- Schalten (Ein/Aus)
- Effekt vor/zurück schalten (durch alle 24 Effekte)
- Helligkeit (DPT 5) 0-255 steuern
- Geschwindigkeit (DPT 5) 0-255 steuern
- Effekte direkt anwählen (DPT 5)
- RGB Farben setzen (DPT 232.600)
- Dimmen TW
- Dimmen HSV)
- RGB Farben setzen (DPT 232.600)
- Serielle Konsole: Alle Aktionen und Telegramme lassen sich zusätzlich über den Seriellen Monitor überwachen und steuern.
Der komplette Sketch in einer Datei: Beinhaltet das WLAN- und NTP Setup, den Verbindungsaufbau, ein Sende Handling mit ACK Überwachung sowie eine automatische Erkennung eingehender Telegramme
(unterstützt die Datentypen DPT 1, 4, 5, 9, 10, 11, 14, 16, 232).
ESP32_KNX_RGB.ino
// ===============================================================================================================================
// ESP32-S3 RGB CONTROLLER + KNX/IP TUNNELING
// ===============================================================================================================================
// Basis: Analoger RGB-PWM-Controller (Pins 4/5/6), 24 Programme
// + Manuelle Farbwahl / Master-Helligkeit / Speed
// KNX Stack 1:1 aus dem WS2812B Sketch übernommen und an das
// ===============================================================================================================================
#include <WiFi.h>
#include <WiFiUdp.h>
#include <WebServer.h>
#include <Preferences.h>
#include <ESPmDNS.h>
#include <time.h>
const char* projectName = "KNX RGB Controller";
// ===============================================================================================================================
// WLAN / NTP
// ===============================================================================================================================
const char* WIFI_SSID = "WLAN SSID";
const char* WIFI_PASSWORD = "WLAN PASSWORT";
const char* HOSTNAME = "led-dimmer";
const char* NTP_SERVER = "de.pool.ntp.org";
const char* TZ_INFO = "CET-1CEST,M3.5.0,M10.5.0/3";
// ===============================================================================================================================
// KNX/IP GATEWAY
// ===============================================================================================================================
const char* knxGatewayIp = "IP von KNX gateway";
const uint16_t knxPort = 3671;
const uint16_t localPort = 3671;
WiFiUDP udp;
WebServer server(80);
Preferences preferences;
uint8_t knxChannelId = 0;
uint8_t txSeqNum = 0;
bool isKnxConnected = false;
unsigned long lastHeartbeat = 0;
const unsigned long HEARTBEAT_INTERVAL = 60000;
const unsigned long ACK_TIMEOUT = 1000;
#define KNX_LOG_SIZE 10
struct KnxLogEntry {
char timeStr[24];
uint16_t ga;
uint8_t data[16];
uint8_t len;
};
KnxLogEntry knxLog[KNX_LOG_SIZE];
int knxLogNext = 0;
int knxLogFilled = 0;
// ===============================================================================================================================
// RGB PWM HARDWARE (Pins 4/5/6)
// ===============================================================================================================================
const uint8_t PIN_RED = 4;
const uint8_t PIN_GREEN = 5;
const uint8_t PIN_BLUE = 6;
const uint32_t PWM_FREQUENCY = 4000;
const uint8_t PWM_RESOLUTION = 10;
const uint32_t PWM_MAX = (1UL << PWM_RESOLUTION) - 1;
// ===============================================================================================================================
// RGB / EFFEKT ZUSTAND
// ===============================================================================================================================
bool ledOn = true; // Strip global an/aus
int targetR = 255, targetG = 255, targetB = 255;
int targetMaster = 100; // Master Helligkeit in % (0-100)
int activeEffect = 0; // 0 = manuelle Farbe, 1-24 = Programme
int effectSpeed = 50; // 1-100 (intern), KNX Gruppenadressen nutzt 0-255
int fxR = 255, fxG = 0, fxB = 0; // Farbe für die Programme
float currentR = 0.0f, currentG = 0.0f, currentB = 0.0f;
float startR = 0.0f, startG = 0.0f, startB = 0.0f;
uint32_t fadeDuration = 300;
unsigned long fadeStartTime = 0;
bool isFading = false;
unsigned long lastEffectStep = 0;
int effectStep = 0;
bool effectState = false;
int colorTempPercent = 50; // 0-100% (Farbwechsel Weiß / Farbtemperatur, Terrasse)
int hueDegrees = 0; // 0-359° (Farbwechsel HSV Farbton, Terrasse)
const uint8_t NUM_EFFECTS = 24; // Programme 1-24 (0 = manuelle Farbe)
const char* effectNames[NUM_EFFECTS + 1] = {
"Manuelle Farbe",
"Fade Pulse", "Fast Pulse", "Knight Rider", "Stroboskop",
"Blitz-Strobo", "Atmung", "Kerzenflackern", "Martinshorn",
"Gewitter-Blitze", "Feuerflackern", "Random Takt", "Smooth Waves",
"Dimmer Cycle", "Sparkle / Funken", "Dual Flash", "Herzschlag",
"Rainbow Loop", "Rainbow Chase", "Party Strobo", "Disko Random",
"Aurora Ozean", "Sunset Fire", "Neon Party", "Polizei Blau/Rot"
};
// ===============================================================================================================================
// GRUPPENADRESSEN FUER DIE RGB STEUERUNG (bitte anpassen)
// ===============================================================================================================================
const char* gaLedOnOff = "1/0/1"; // DPT1 - 0=Aus, 1=An
const char* gaEffectStep = "3/0/0"; // DPT1 - 0=voriges Programm, 1=naechstes Programm
const char* gaEffectSet = "3/0/2"; // DPT5 - Programm direkt setzen (0-24)
const char* gaColorRGB = "3/0/3"; // DPT232.600 - 3 Byte RGB Farbe
const char* gaBrightness = "3/0/4"; // DPT5 - Master Helligkeit 0-255 (-> 0-100%)
const char* gaSpeed = "3/0/5"; // DPT5 - Effekt Geschwindigkeit 0-255 (-> 1-100%)
// Terrassenbeleuchtung - Farbwechsel Taster (aus der KNX-Projektliste uebernommen)
const char* gaColorTempStep = "1/6/5"; // DPT3 (4 Bit) - Farbtemperatur/Weiß relativ aendern (Taste)
const char* gaColorTempStatus = "10/0/27"; // DPT5.001 - Status Farbtemperatur, Prozent 0-100%
const char* gaHueStep = "1/6/6"; // DPT3 (4 Bit) - HSV Farbton (H) relativ aendern (Taste)
const char* gaHueStatus = "10/0/28"; // DPT5.003 - Status Farbton (H), Winkel 0-360°
uint16_t gaLedOnOffParsed, gaEffectStepParsed, gaEffectSetParsed, gaColorRGBParsed, gaBrightnessParsed, gaSpeedParsed;
uint16_t gaColorTempStepParsed, gaHueStepParsed;
// ===============================================================================================================================
// KNX HILFSFUNKTIONEN (unveraendert aus dem WS2812B Sketch uebernommen)
// ===============================================================================================================================
const char* knxStatusToString(uint8_t status) {
switch (status) {
case 0x00: return "E_NO_ERROR";
case 0x01: return "E_HOST_PROTOCOL_TYPE";
case 0x02: return "E_VERSION_NOT_SUPPORTED";
case 0x04: return "E_SEQUENCE_NUMBER (Sequenznummer falsch/ausser Sync)";
case 0x21: return "E_CONNECTION_ID (unbekannte Channel ID)";
case 0x22: return "E_CONNECTION_TYPE";
case 0x23: return "E_CONNECTION_OPTION";
case 0x24: return "E_NO_MORE_CONNECTIONS (Gateway voll)";
case 0x26: return "E_DATA_CONNECTION";
case 0x27: return "E_KNX_CONNECTION";
case 0x29: return "E_TUNNELING_LAYER (nicht unterstuetzt)";
default: return "Unbekannter Statuscode";
}
}
String getFormattedDateTime() {
struct tm timeinfo;
if (!getLocalTime(&timeinfo, 100)) {
return "Zeit nicht synchronisiert";
}
char buf[32];
strftime(buf, sizeof(buf), "%d.%m.%Y %H:%M:%S", &timeinfo);
return String(buf);
}
unsigned long getEpochTime() {
time_t now;
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) return 0;
time(&now);
return now;
}
uint16_t parseGroupAddress(const String& gaStr) {
int firstSlash = gaStr.indexOf('/');
int secondSlash = gaStr.indexOf('/', firstSlash + 1);
if (firstSlash == -1 || secondSlash == -1) return 0;
uint8_t main = gaStr.substring(0, firstSlash).toInt();
uint8_t middle = gaStr.substring(firstSlash + 1, secondSlash).toInt();
uint8_t sub = gaStr.substring(secondSlash + 1).toInt();
return ((main & 0x1F) << 11) | ((middle & 0x07) << 8) | (sub & 0xFF);
}
String gaToString(uint16_t ga) {
uint8_t main = (ga >> 11) & 0x1F;
uint8_t middle = (ga >> 8) & 0x07;
uint8_t sub = ga & 0xFF;
return String(main) + "/" + String(middle) + "/" + String(sub);
}
uint16_t floatToDpt9(float val) {
int sign = (val < 0) ? 1 : 0;
float v = (sign == 1) ? -val : val;
int exp = 0;
int mantissa = (int)(v * 100.0f);
while (mantissa > 2047) {
mantissa >>= 1;
exp++;
}
if (sign == 1) {
mantissa = -mantissa;
mantissa &= 0x07FF;
}
return (sign << 15) | ((exp & 0x0F) << 11) | (mantissa & 0x07FF);
}
float dpt9ToFloat(uint8_t hi, uint8_t lo) {
uint16_t raw = ((uint16_t)hi << 8) | lo;
int sign = (raw >> 15) & 0x01;
int exp = (raw >> 11) & 0x0F;
int mantissa11 = raw & 0x07FF;
int M = mantissa11 - (sign ? 2048 : 0);
return 0.01f * M * (float)(1 << exp);
}
void floatToDpt14Bytes(float val, uint8_t* out4) {
uint8_t* p = (uint8_t*)&val;
out4[0] = p[3];
out4[1] = p[2];
out4[2] = p[1];
out4[3] = p[0];
}
float dpt14ToFloat(uint8_t b0, uint8_t b1, uint8_t b2, uint8_t b3) {
uint8_t bytes[4] = { b3, b2, b1, b0 };
float f;
memcpy(&f, bytes, 4);
return f;
}
String apciTypeStr(uint8_t apciByte) {
switch (apciByte & 0xC0) {
case 0x00: return "Read";
case 0x40: return "Response";
case 0x80: return "Write";
default: return "?";
}
}
String interpretKnxData(uint8_t* data, uint8_t len) {
String prefix = "[" + apciTypeStr(data[0]) + "] ";
if (len == 1) {
uint8_t raw = data[0] & 0x0F;
return prefix + "1/2/4-Bit: 0x" + String(raw, HEX) + " (DPT1/2/3)";
}
else if (len == 2) {
return prefix + "DPT5: " + String(data[1]) + " (0..255)";
}
else if (len == 3) {
float f = dpt9ToFloat(data[1], data[2]);
return prefix + "DPT9: " + String(f, 2);
}
else if (len == 4) {
uint8_t h = data[1] & 0x1F, mnt = data[2] & 0x3F, s = data[3] & 0x3F;
uint8_t day = data[1] & 0x1F, month = data[2] & 0x0F, year = data[3] & 0x7F;
char buf[110];
snprintf(buf, sizeof(buf), "DPT10 Zeit: %02d:%02d:%02d / DPT11 Datum: %02d.%02d.20%02d / DPT232 RGB: #%02X%02X%02X",
h, mnt, s, day, month, year, data[1], data[2], data[3]);
return prefix + String(buf);
}
else if (len == 5) {
float f = dpt14ToFloat(data[1], data[2], data[3], data[4]);
return prefix + "DPT14: " + String(f, 3);
}
else if (len == 15) {
char txt[15];
for (int i = 0; i < 14; i++) {
uint8_t c = data[1 + i];
txt[i] = (c >= 32 && c < 127) ? (char)c : (c == 0 ? 0 : '.');
}
txt[14] = 0;
return prefix + "DPT16: \"" + String(txt) + "\"";
}
return prefix + "Unbekannt (" + String(len) + " Byte)";
}
void addKnxLogEntry(uint16_t ga, uint8_t* rxBuf, uint8_t npduLen) {
KnxLogEntry &e = knxLog[knxLogNext];
String t = getFormattedDateTime();
strncpy(e.timeStr, t.c_str(), sizeof(e.timeStr) - 1);
e.timeStr[sizeof(e.timeStr) - 1] = 0;
e.ga = ga;
uint8_t copyLen = npduLen;
if (copyLen > sizeof(e.data)) copyLen = sizeof(e.data);
memcpy(e.data, &rxBuf[20], copyLen);
e.len = copyLen;
knxLogNext = (knxLogNext + 1) % KNX_LOG_SIZE;
if (knxLogFilled < KNX_LOG_SIZE) knxLogFilled++;
}
// ===============================================================================================================================
// RGB HILFSFUNKTIONEN
// ===============================================================================================================================
void hsvToRgb(uint16_t h, uint8_t s, uint8_t v, int &r, int &g, int &b) {
unsigned char region, remainder, p, q, t;
if (s == 0) { r = g = b = v; return; }
region = h / 43;
remainder = (h - (region * 43)) * 6;
p = (v * (255 - s)) >> 8;
q = (v * (255 - ((s * remainder) >> 8))) >> 8;
t = (v * (255 - ((s * (255 - remainder)) >> 8))) >> 8;
switch (region) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
default: r = v; g = p; b = q; break;
}
}
void saveRGB() {
preferences.putInt("r", targetR);
preferences.putInt("g", targetG);
preferences.putInt("b", targetB);
preferences.putInt("m", targetMaster);
preferences.putInt("speed", effectSpeed);
preferences.putInt("fx", activeEffect);
preferences.putInt("fxr", fxR);
preferences.putInt("fxg", fxG);
preferences.putInt("fxb", fxB);
preferences.putBool("on", ledOn);
preferences.putInt("ctemp", colorTempPercent);
preferences.putInt("hue", hueDegrees);
}
void loadSettings() {
targetR = preferences.getInt("r", 255);
targetG = preferences.getInt("g", 255);
targetB = preferences.getInt("b", 255);
targetMaster = preferences.getInt("m", 100);
effectSpeed = preferences.getInt("speed", 50);
activeEffect = preferences.getInt("fx", 0);
fxR = preferences.getInt("fxr", 255);
fxG = preferences.getInt("fxg", 0);
fxB = preferences.getInt("fxb", 0);
ledOn = preferences.getBool("on", true);
colorTempPercent = preferences.getInt("ctemp", 50);
hueDegrees = preferences.getInt("hue", 0);
currentR = (targetR * targetMaster) / 100.0f;
currentG = (targetG * targetMaster) / 100.0f;
currentB = (targetB * targetMaster) / 100.0f;
}
void writePWMHardware(float r, float g, float b) {
uint32_t dutyR = map((long)(r * 100), 0, 25500, 0, PWM_MAX);
uint32_t dutyG = map((long)(g * 100), 0, 25500, 0, PWM_MAX);
uint32_t dutyB = map((long)(b * 100), 0, 25500, 0, PWM_MAX);
ledcWrite(PIN_RED, dutyR);
ledcWrite(PIN_GREEN, dutyG);
ledcWrite(PIN_BLUE, dutyB);
}
void setDirectRGB(int r, int g, int b) {
float masterFactor = targetMaster / 100.0f;
currentR = constrain(r, 0, 255) * masterFactor;
currentG = constrain(g, 0, 255) * masterFactor;
currentB = constrain(b, 0, 255) * masterFactor;
writePWMHardware(currentR, currentG, currentB);
}
void startRGBFade(int r, int g, int b, int m, uint32_t duration = 300) {
targetR = constrain(r, 0, 255);
targetG = constrain(g, 0, 255);
targetB = constrain(b, 0, 255);
targetMaster = constrain(m, 0, 100);
startR = currentR;
startG = currentG;
startB = currentB;
fadeDuration = duration;
fadeStartTime = millis();
isFading = true;
}
void updateFade() {
if (!isFading) return;
unsigned long elapsed = millis() - fadeStartTime;
float masterFactor = targetMaster / 100.0f;
float finalTargetR = targetR * masterFactor;
float finalTargetG = targetG * masterFactor;
float finalTargetB = targetB * masterFactor;
if (elapsed >= fadeDuration) {
currentR = finalTargetR;
currentG = finalTargetG;
currentB = finalTargetB;
writePWMHardware(currentR, currentG, currentB);
isFading = false;
} else {
float progress = (float)elapsed / (float)fadeDuration;
currentR = startR + (finalTargetR - startR) * progress;
currentG = startG + (finalTargetG - startG) * progress;
currentB = startB + (finalTargetB - startB) * progress;
writePWMHardware(currentR, currentG, currentB);
}
}
// ===============================================================================================================================
// EFFEKT ENGINE - 16 SINGLE COLOR + 8 MULTI-COLOR (unveraendert)
// ===============================================================================================================================
void processEffects() {
if (activeEffect == 0) return;
unsigned long now = millis();
int interval = map(effectSpeed, 1, 100, 400, 5);
if (now - lastEffectStep < interval) return;
lastEffectStep = now;
int r = 0, g = 0, b = 0;
switch (activeEffect) {
case 1: // Fade Pulse
effectStep = (effectStep + 4) % 360;
{
float factor = (sin(effectStep * 0.0174533f) + 1.0f) / 2.0f;
setDirectRGB(fxR * factor, fxG * factor, fxB * factor);
}
break;
case 2: // Fast Pulse
effectStep = (effectStep + 15) % 360;
{
float factor = (sin(effectStep * 0.0174533f) + 1.0f) / 2.0f;
setDirectRGB(fxR * factor, fxG * factor, fxB * factor);
}
break;
case 3: // Knight Rider
effectStep = (effectStep + 1) % 20;
{
float val = (effectStep < 10) ? (effectStep / 10.0f) : ((20 - effectStep) / 10.0f);
setDirectRGB(fxR * val, fxG * val, fxB * val);
}
break;
case 4: // Stroboskop
effectState = !effectState;
if (effectState) { setDirectRGB(fxR, fxG, fxB); }
else { setDirectRGB(0, 0, 0); }
break;
case 5: // Blitz-Strobo
effectStep = (effectStep + 1) % 4;
if (effectStep == 0) { setDirectRGB(fxR, fxG, fxB); }
else { setDirectRGB(0, 0, 0); }
break;
case 6: // Atmung
effectStep = (effectStep + 2) % 360;
{
float factor = (sin(effectStep * 0.0174533f) + 1.0f) / 2.0f;
setDirectRGB(fxR * factor, fxG * factor, fxB * factor);
}
break;
case 7: // Kerzenflackern
{
float factor = random(40, 100) / 100.0f;
setDirectRGB(fxR * factor, fxG * factor, fxB * factor);
}
break;
case 8: // Martinshorn (Signal)
effectStep = (effectStep + 1) % 4;
if (effectStep == 0 || effectStep == 1) setDirectRGB(fxR, fxG, fxB);
else setDirectRGB(fxR * 0.05, fxG * 0.05, fxB * 0.05);
break;
case 9: // Gewitter-Blitze
if (random(0, 10) > 7) {
setDirectRGB(fxR, fxG, fxB);
} else {
setDirectRGB(fxR * 0.05, fxG * 0.05, fxB * 0.05);
}
break;
case 10: // Feuerflackern
{
float factor = random(20, 100) / 100.0f;
setDirectRGB(fxR * factor, fxG * factor, fxB * factor);
}
break;
case 11: // Random Takt
{
float factor = random(10, 100) / 100.0f;
setDirectRGB(fxR * factor, fxG * factor, fxB * factor);
}
break;
case 12: // Smooth Waves
effectStep = (effectStep + 2) % 360;
{
float factor = (sin(effectStep * 0.0174533f) + 1.0f) / 2.0f;
setDirectRGB(fxR * factor, fxG * factor, fxB * factor);
}
break;
case 13: // Dimmer Cycle
effectStep = (effectStep + 5) % 100;
{
float factor = effectStep / 100.0f;
setDirectRGB(fxR * factor, fxG * factor, fxB * factor);
}
break;
case 14: // Sparkle / Funken
if (random(0, 5) == 0) setDirectRGB(fxR, fxG, fxB);
else setDirectRGB(fxR * 0.1, fxG * 0.1, fxB * 0.1);
break;
case 15: // Dual Flash
effectStep = (effectStep + 1) % 6;
if (effectStep == 0 || effectStep == 2) setDirectRGB(fxR, fxG, fxB);
else setDirectRGB(0, 0, 0);
break;
case 16: // Herzschlag
effectStep = (effectStep + 1) % 8;
if (effectStep == 0 || effectStep == 2) setDirectRGB(fxR, fxG, fxB);
else setDirectRGB(fxR * 0.05, fxG * 0.05, fxB * 0.05);
break;
case 17: // Rainbow Loop
effectStep = (effectStep + 2) % 256;
hsvToRgb(effectStep, 255, 255, r, g, b);
setDirectRGB(r, g, b);
break;
case 18: // Rainbow Chase
effectStep = (effectStep + 8) % 256;
hsvToRgb(effectStep, 255, 255, r, g, b);
setDirectRGB(r, g, b);
break;
case 19: // Party Strobo
effectState = !effectState;
if (effectState) { setDirectRGB(random(0,256), random(0,256), random(0,256)); }
else { setDirectRGB(0, 0, 0); }
break;
case 20: // Disko Random
setDirectRGB(random(0,256), random(0,256), random(0,256));
break;
case 21: // Aurora Ozean
effectStep = (effectStep + 2) % 256;
r = 0;
g = map(sin(effectStep * 0.05), -1, 1, 100, 255);
b = map(cos(effectStep * 0.05), -1, 1, 150, 255);
setDirectRGB(r, g, b);
break;
case 22: // Sunset Fire
effectStep = (effectStep + 1) % 256;
hsvToRgb((effectStep % 45), 255, 255, r, g, b);
setDirectRGB(r, g, b);
break;
case 23: // Neon Party
effectStep = (effectStep + 1) % 3;
if (effectStep == 0) setDirectRGB(255, 0, 128);
else if (effectStep == 1) setDirectRGB(0, 255, 255);
else setDirectRGB(128, 0, 255);
break;
case 24: // Polizei Blau/Rot
effectStep = (effectStep + 1) % 6;
if (effectStep == 0 || effectStep == 1) setDirectRGB(0, 0, 255);
else if (effectStep == 3 || effectStep == 4) setDirectRGB(255, 0, 0);
else setDirectRGB(0, 0, 0);
break;
}
}
// ===============================================================================================================================
// ZENTRALE APPLY FUNKTIONEN
// Werden von Web UI, seriellen Befehlen UND eingehenden KNX Telegrammen genutzt. fromKnx=true unterdrueckt das Zurueck-
// senden auf den Bus (verhindert Echo-Schleifen).
// ===============================================================================================================================
bool sendKnxValue(String groupAddress, String dptType, String rawValue); // Forward-Deklaration
void applyOnOff(bool on, bool fromKnx = false) {
ledOn = on;
if (on && activeEffect == 0) {
startRGBFade(targetR, targetG, targetB, targetMaster);
}
saveRGB();
if (!fromKnx) sendKnxValue(gaLedOnOff, "DPT1", on ? "1" : "0");
}
void applyEffectStep(bool up, bool fromKnx = false) {
activeEffect = (activeEffect + (up ? 1 : (int)NUM_EFFECTS)) % (NUM_EFFECTS + 1);
effectStep = 0; effectState = false; isFading = false;
saveRGB();
if (!fromKnx) sendKnxValue(gaEffectStep, "DPT1", up ? "1" : "0");
}
void applyEffectSet(int e, bool fromKnx = false) {
if (e < 0 || e > NUM_EFFECTS) return;
activeEffect = e;
effectStep = 0; effectState = false; isFading = false;
saveRGB();
if (!fromKnx) sendKnxValue(gaEffectSet, "DPT5", String(e));
}
void applyColor(int r, int g, int b, bool fromKnx = false) {
r = constrain(r, 0, 255); g = constrain(g, 0, 255); b = constrain(b, 0, 255);
fxR = r; fxG = g; fxB = b;
if (activeEffect == 0) {
startRGBFade(r, g, b, targetMaster);
}
saveRGB();
if (!fromKnx) sendKnxValue(gaColorRGB, "DPT232", String(r) + "," + String(g) + "," + String(b));
}
void applyMasterPercent(int percent, bool fromKnx = false) {
percent = constrain(percent, 0, 100);
targetMaster = percent;
if (activeEffect == 0 && !isFading) setDirectRGB(targetR, targetG, targetB);
saveRGB();
if (!fromKnx) sendKnxValue(gaBrightness, "DPT5", String(map(percent, 0, 100, 0, 255)));
}
void applySpeedPercent(int percent, bool fromKnx = false) {
percent = constrain(percent, 1, 100);
effectSpeed = percent;
saveRGB();
if (!fromKnx) sendKnxValue(gaSpeed, "DPT5", String(map(percent, 1, 100, 0, 255)));
}
// ===============================================================================================================================
// ECHTES 4BIT DIMMEN (Start-/Stop-Rampe) fuer Farbtemperatur & HSV-Farbton
// Ein KNX-Taster sendet beim Druecken EIN Start-Telegramm (Richtung im Steuerbit) und beim Loslassen EIN Stop Telegramm
// (Step-Code 0). Die eigentliche kontinuierliche Rampe waehrend des Haltens erzeugt der Empfaenger selbst,
// das passiert unten in processColorTempDim()/processHueDim(), die aus loop() heraus aufgerufen werden.
// ===============================================================================================================================
bool colorTempDimActive = false;
bool colorTempDimUp = false;
unsigned long colorTempLastStep = 0;
unsigned long colorTempLastKnxSend = 0;
const unsigned long COLORTEMP_STEP_INTERVAL_MS = 40; // ~4s fuer 0->100%
const unsigned long DIM_STATUS_INTERVAL_MS = 300; // Status waehrend des Haltens nicht bei jedem Schritt senden
bool hueDimActive = false;
bool hueDimUp = false;
unsigned long hueLastStep = 0;
unsigned long hueLastKnxSend = 0;
const unsigned long HUE_STEP_INTERVAL_MS = 15; // ~5.4s fuer eine volle Umdrehung
void updateColorTempColor() {
float t = colorTempPercent / 100.0f;
int r = round(255 + (200 - 255) * t); // warm (255,150,60) -> kalt (200,220,255)
int g = round(150 + (220 - 150) * t);
int b = round(60 + (255 - 60) * t);
fxR = r; fxG = g; fxB = b;
if (activeEffect == 0) {
targetR = r; targetG = g; targetB = b;
startRGBFade(r, g, b, targetMaster, 80); // kurze Fade Zeit = weiches Dimmen ohne Ruckeln
}
}
void sendColorTempStatus() {
saveRGB();
sendKnxValue(gaColorTempStatus, "DPT5", String(map(colorTempPercent, 0, 100, 0, 255)));
}
void updateHueColor() {
uint8_t hue8 = (uint8_t)round(hueDegrees / 360.0f * 255.0f);
int r, g, b;
hsvToRgb(hue8, 255, 255, r, g, b);
fxR = r; fxG = g; fxB = b;
if (activeEffect == 0) {
targetR = r; targetG = g; targetB = b;
startRGBFade(r, g, b, targetMaster, 80);
}
}
void sendHueStatus() {
saveRGB();
uint8_t hue8 = (uint8_t)round(hueDegrees / 360.0f * 255.0f);
sendKnxValue(gaHueStatus, "DPT5", String(hue8));
}
// Von einem eingehenden 1/6/5-Telegramm aufgerufen (kein Re-Send hier!)
void startColorTempDim(bool up) {
colorTempDimActive = true;
colorTempDimUp = up;
colorTempLastStep = millis();
colorTempLastKnxSend = millis();
}
void stopColorTempDim() {
if (!colorTempDimActive) return;
colorTempDimActive = false;
sendColorTempStatus(); // finalen Wert immer melden
}
// Von einem eingehenden 1/6/6-Telegramm aufgerufen (kein Re-Send hier!)
void startHueDim(bool up) {
hueDimActive = true;
hueDimUp = up;
hueLastStep = millis();
hueLastKnxSend = millis();
}
void stopHueDim() {
if (!hueDimActive) return;
hueDimActive = false;
sendHueStatus();
}
// Werden aus loop() heraus laufend aufgerufen, solange der Controller an ist
void processColorTempDim() {
if (!colorTempDimActive) return;
unsigned long now = millis();
if (now - colorTempLastStep < COLORTEMP_STEP_INTERVAL_MS) return;
colorTempLastStep = now;
int next = colorTempPercent + (colorTempDimUp ? 1 : -1);
bool atLimit = false;
if (next <= 0) { next = 0; atLimit = true; }
if (next >= 100) { next = 100; atLimit = true; }
colorTempPercent = next;
updateColorTempColor();
if (atLimit) {
colorTempDimActive = false; // am Anschlag automatisch stoppen
sendColorTempStatus();
} else if (now - colorTempLastKnxSend >= DIM_STATUS_INTERVAL_MS) {
sendColorTempStatus();
colorTempLastKnxSend = now;
}
}
void processHueDim() {
if (!hueDimActive) return;
unsigned long now = millis();
if (now - hueLastStep < HUE_STEP_INTERVAL_MS) return;
hueLastStep = now;
hueDegrees = (hueDegrees + (hueDimUp ? 1 : -1) + 360) % 360;
updateHueColor();
if (now - hueLastKnxSend >= DIM_STATUS_INTERVAL_MS) {
sendHueStatus();
hueLastKnxSend = now;
}
}
// Wird nur von eingehenden KNX-Telegrammen aufgerufen (kein Re-Send!)
void applyKnxTelegram(uint16_t ga, uint8_t* rxBuf, uint8_t npduLen) {
if (ga == gaLedOnOffParsed && npduLen == 1) {
applyOnOff(rxBuf[20] & 0x01, true);
}
else if (ga == gaEffectStepParsed && npduLen == 1) {
applyEffectStep(rxBuf[20] & 0x01, true);
}
else if (ga == gaEffectSetParsed && npduLen == 2) {
applyEffectSet(rxBuf[21], true);
}
else if (ga == gaColorRGBParsed && npduLen == 4) {
applyColor(rxBuf[21], rxBuf[22], rxBuf[23], true);
}
else if (ga == gaBrightnessParsed && npduLen == 2) {
applyMasterPercent(map(rxBuf[21], 0, 255, 0, 100), true);
}
else if (ga == gaSpeedParsed && npduLen == 2) {
applySpeedPercent(map(rxBuf[21], 0, 255, 1, 100), true);
}
else if (ga == gaColorTempStepParsed && npduLen == 1) {
uint8_t raw = rxBuf[20] & 0x0F;
uint8_t stepCode = raw & 0x07;
if (stepCode == 0) stopColorTempDim(); // Loslassen -> Stop-Telegramm
else startColorTempDim((raw & 0x08) != 0); // Druecken -> Start-Telegramm mit Richtung
}
else if (ga == gaHueStepParsed && npduLen == 1) {
uint8_t raw = rxBuf[20] & 0x0F;
uint8_t stepCode = raw & 0x07;
if (stepCode == 0) stopHueDim();
else startHueDim((raw & 0x08) != 0);
}
}
// ===============================================================================================================================
// KNX PROTOKOLL (unveraendert aus dem WS2812B-Sketch uebernommen,
// applyLedTelegram() -> applyKnxTelegram())
// ===============================================================================================================================
uint16_t processIncomingPacket(uint8_t* rxBuf, int len) {
if (len < 6) return 0;
uint16_t serviceType = (rxBuf[2] << 8) | rxBuf[3];
switch (serviceType) {
case 0x0420:
if (len >= 21) {
uint8_t rxChannel = rxBuf[7];
uint8_t rxSeqNum = rxBuf[8];
uint16_t ga = ((uint16_t)rxBuf[16] << 8) | rxBuf[17];
uint8_t npduLen = rxBuf[18];
if (rxChannel == knxChannelId) {
addKnxLogEntry(ga, rxBuf, npduLen);
applyKnxTelegram(ga, rxBuf, npduLen);
uint8_t ackBuf[10] = {
0x06, 0x10, 0x04, 0x21, 0x00, 0x0A, 0x04, knxChannelId, rxSeqNum, 0x00
};
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(ackBuf, sizeof(ackBuf));
udp.endPacket();
}
}
break;
case 0x0208:
if (len >= 8 && rxBuf[7] != 0x00) {
Serial.println("[KNX] Heartbeat fehlgeschlagen -> Tunnel getrennt");
isKnxConnected = false;
}
break;
case 0x0209:
if (len >= 8) {
uint8_t rxChannel = rxBuf[6];
if (rxChannel == knxChannelId) {
uint8_t discResp[8] = { 0x06, 0x10, 0x02, 0x0A, 0x00, 0x08, knxChannelId, 0x00 };
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(discResp, sizeof(discResp));
udp.endPacket();
Serial.println("[KNX] Vom Gateway getrennt");
isKnxConnected = false;
}
}
break;
default:
break;
}
return serviceType;
}
bool connectKnx(bool verbose = false) {
IPAddress myIp = WiFi.localIP();
if (verbose) {
Serial.println("[KNX] Sende CONNECT_REQUEST an Gateway...");
Serial.printf(" Lokale IP/Port die wir dem Gateway melden: %s:%d\n", myIp.toString().c_str(), localPort);
Serial.printf(" Ziel-Gateway: %s:%d\n", knxGatewayIp, knxPort);
}
uint8_t connReq[] = {
0x06, 0x10, 0x02, 0x05, 0x00, 0x1A,
0x08, 0x01, myIp[0], myIp[1], myIp[2], myIp[3], (uint8_t)(localPort >> 8), (uint8_t)(localPort & 0xFF),
0x08, 0x01, myIp[0], myIp[1], myIp[2], myIp[3], (uint8_t)(localPort >> 8), (uint8_t)(localPort & 0xFF),
0x04, 0x04, 0x02, 0x00
};
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(connReq, sizeof(connReq));
udp.endPacket();
unsigned long start = millis();
while (millis() - start < 2000) {
int size = udp.parsePacket();
if (size >= 8) {
uint8_t resp[32];
int rlen = udp.read(resp, sizeof(resp));
if (resp[2] == 0x02 && resp[3] == 0x06) {
if (resp[7] == 0x00) {
knxChannelId = resp[6];
txSeqNum = 0;
isKnxConnected = true;
lastHeartbeat = millis();
if (verbose) Serial.printf("[KNX] Verbunden (Channel %d)\n", knxChannelId);
return true;
} else {
if (verbose) Serial.printf("[KNX] Verbindung fehlgeschlagen: %s\n", knxStatusToString(resp[7]));
return false;
}
}
}
delay(10);
}
if (verbose) Serial.println("[KNX] Timeout beim Verbindungsaufbau");
isKnxConnected = false;
return false;
}
void sendConnectionStateRequest() {
IPAddress myIp = WiFi.localIP();
uint8_t req[16] = {
0x06, 0x10, 0x02, 0x07, 0x00, 0x10,
knxChannelId, 0x00,
0x08, 0x01, myIp[0], myIp[1], myIp[2], myIp[3], (uint8_t)(localPort >> 8), (uint8_t)(localPort & 0xFF)
};
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(req, sizeof(req));
udp.endPacket();
}
void disconnectKnx() {
if (!isKnxConnected) return;
IPAddress myIp = WiFi.localIP();
uint8_t req[16] = {
0x06, 0x10, 0x02, 0x09, 0x00, 0x10,
knxChannelId, 0x00,
0x08, 0x01, myIp[0], myIp[1], myIp[2], myIp[3], (uint8_t)(localPort >> 8), (uint8_t)(localPort & 0xFF)
};
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(req, sizeof(req));
udp.endPacket();
isKnxConnected = false;
}
void handleKnxRx() {
int packetSize = udp.parsePacket();
if (packetSize <= 0) return;
uint8_t rxBuf[64];
int len = udp.read(rxBuf, sizeof(rxBuf));
processIncomingPacket(rxBuf, len);
}
bool sendKnxValue(String groupAddress, String dptType, String rawValue) {
if (!isKnxConnected) {
if (!connectKnx()) return false;
}
uint16_t ga = parseGroupAddress(groupAddress);
uint8_t buf[64];
uint8_t payloadLen = 0;
buf[0] = 0x06; buf[1] = 0x10;
buf[2] = 0x04; buf[3] = 0x20;
buf[4] = 0x00; buf[5] = 0x00;
buf[6] = 0x04;
buf[7] = knxChannelId;
buf[8] = txSeqNum;
buf[9] = 0x00;
buf[10] = 0x11;
buf[11] = 0x00;
buf[12] = 0xBC;
buf[13] = 0xE0;
buf[14] = 0x00; buf[15] = 0x00;
buf[16] = (uint8_t)(ga >> 8);
buf[17] = (uint8_t)(ga & 0xFF);
if (dptType == "DPT1") {
buf[18] = 0x01;
buf[19] = 0x00;
buf[20] = (rawValue.toInt() > 0) ? 0x81 : 0x80;
payloadLen = 21;
}
else if (dptType == "DPT2") {
buf[18] = 0x01;
buf[19] = 0x00;
buf[20] = 0x80 | ((uint8_t)rawValue.toInt() & 0x03);
payloadLen = 21;
}
else if (dptType == "DPT3") {
int v = rawValue.toInt();
uint8_t dir = (v >= 0) ? 0x08 : 0x00;
uint8_t step = (uint8_t)abs(v) & 0x07;
buf[18] = 0x01;
buf[19] = 0x00;
buf[20] = 0x80 | dir | step;
payloadLen = 21;
}
else if (dptType == "DPT5") {
buf[18] = 0x02;
buf[19] = 0x00;
buf[20] = 0x80;
buf[21] = (uint8_t)rawValue.toInt();
payloadLen = 22;
}
else if (dptType == "DPT9") {
uint16_t dpt9Val = floatToDpt9(rawValue.toFloat());
buf[18] = 0x03;
buf[19] = 0x00;
buf[20] = 0x80;
buf[21] = (uint8_t)(dpt9Val >> 8);
buf[22] = (uint8_t)(dpt9Val & 0xFF);
payloadLen = 23;
}
else if (dptType == "DPT10") {
int h = 0, m = 0, s = 0;
int c1 = rawValue.indexOf(':');
if (c1 == -1) {
h = rawValue.toInt();
} else {
h = rawValue.substring(0, c1).toInt();
int c2 = rawValue.indexOf(':', c1 + 1);
if (c2 == -1) {
m = rawValue.substring(c1 + 1).toInt();
} else {
m = rawValue.substring(c1 + 1, c2).toInt();
s = rawValue.substring(c2 + 1).toInt();
}
}
buf[18] = 0x04;
buf[19] = 0x00;
buf[20] = 0x80;
buf[21] = (uint8_t)(h & 0x1F);
buf[22] = (uint8_t)(m & 0x3F);
buf[23] = (uint8_t)(s & 0x3F);
payloadLen = 24;
}
else if (dptType == "DPT11") {
int day = 1, month = 1, year = 26;
int p1 = rawValue.indexOf('.');
if (p1 != -1) {
day = rawValue.substring(0, p1).toInt();
int p2 = rawValue.indexOf('.', p1 + 1);
if (p2 != -1) {
month = rawValue.substring(p1 + 1, p2).toInt();
int y = rawValue.substring(p2 + 1).toInt();
year = (y >= 2000) ? (y - 2000) : y;
}
} else {
day = rawValue.toInt();
}
buf[18] = 0x04;
buf[19] = 0x00;
buf[20] = 0x80;
buf[21] = (uint8_t)(day & 0x1F);
buf[22] = (uint8_t)(month & 0x0F);
buf[23] = (uint8_t)(year & 0x7F);
payloadLen = 24;
}
else if (dptType == "DPT14") {
uint8_t db[4];
floatToDpt14Bytes(rawValue.toFloat(), db);
buf[18] = 0x05;
buf[19] = 0x00;
buf[20] = 0x80;
buf[21] = db[0];
buf[22] = db[1];
buf[23] = db[2];
buf[24] = db[3];
payloadLen = 25;
}
else if (dptType == "DPT16") {
uint8_t strLen = rawValue.length();
if (strLen > 14) strLen = 14;
buf[18] = 15;
buf[19] = 0x00;
buf[20] = 0x80;
for (int i = 0; i < 14; i++) {
buf[21 + i] = (i < strLen) ? rawValue[i] : 0x00;
}
payloadLen = 21 + 14;
}
else if (dptType == "DPT232") {
int c1 = rawValue.indexOf(',');
int c2 = (c1 == -1) ? -1 : rawValue.indexOf(',', c1 + 1);
int r = 0, g = 0, b = 0;
if (c1 != -1 && c2 != -1) {
r = rawValue.substring(0, c1).toInt();
g = rawValue.substring(c1 + 1, c2).toInt();
b = rawValue.substring(c2 + 1).toInt();
}
buf[18] = 0x04;
buf[19] = 0x00;
buf[20] = 0x80;
buf[21] = (uint8_t)r;
buf[22] = (uint8_t)g;
buf[23] = (uint8_t)b;
payloadLen = 24;
}
else {
Serial.println("[KNX TX] FEHLER - Unbekannter DPT-Typ!");
return false;
}
buf[5] = payloadLen;
uint8_t sentSeq = txSeqNum;
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(buf, payloadLen);
udp.endPacket();
txSeqNum++;
unsigned long waitStart = millis();
bool ackOk = false;
while (millis() - waitStart < ACK_TIMEOUT) {
int packetSize = udp.parsePacket();
if (packetSize > 0) {
uint8_t rxBuf[64];
int len = udp.read(rxBuf, sizeof(rxBuf));
uint16_t st = processIncomingPacket(rxBuf, len);
if (st == 0x0421 && len >= 9 && rxBuf[8] == sentSeq) {
ackOk = (len >= 10 && rxBuf[9] == 0x00);
break;
}
}
delay(5);
}
if (!ackOk) isKnxConnected = false;
return ackOk;
}
bool sendKnxRead(String groupAddress) {
if (!isKnxConnected) { if (!connectKnx()) return false; }
uint16_t ga = parseGroupAddress(groupAddress);
uint8_t buf[21];
buf[0] = 0x06; buf[1] = 0x10; buf[2] = 0x04; buf[3] = 0x20; buf[4] = 0x00; buf[5] = 21;
buf[6] = 0x04; buf[7] = knxChannelId; buf[8] = txSeqNum; buf[9] = 0x00;
buf[10] = 0x11; buf[11] = 0x00; buf[12] = 0xBC; buf[13] = 0xE0;
buf[14] = 0x00; buf[15] = 0x00;
buf[16] = (uint8_t)(ga >> 8);
buf[17] = (uint8_t)(ga & 0xFF);
buf[18] = 0x01; buf[19] = 0x00; buf[20] = 0x00;
uint8_t sentSeq = txSeqNum;
udp.beginPacket(knxGatewayIp, knxPort);
udp.write(buf, sizeof(buf));
udp.endPacket();
txSeqNum++;
unsigned long waitStart = millis();
bool ackOk = false;
while (millis() - waitStart < ACK_TIMEOUT) {
int packetSize = udp.parsePacket();
if (packetSize > 0) {
uint8_t rxBuf[64];
int len = udp.read(rxBuf, sizeof(rxBuf));
uint16_t st = processIncomingPacket(rxBuf, len);
if (st == 0x0421 && len >= 10 && rxBuf[8] == sentSeq) { ackOk = (rxBuf[9] == 0x00); break; }
}
delay(5);
}
if (!ackOk) isKnxConnected = false;
return ackOk;
}
// ===============================================================================================================================
// SERIELLE BEFEHLE
// ===============================================================================================================================
void handleSerialCommands() {
if (!Serial.available()) return;
String line = Serial.readStringUntil('\n');
line.trim();
if (line.length() == 0) return;
int sp1 = line.indexOf(' ');
String cmd = (sp1 == -1) ? line : line.substring(0, sp1);
String rest = (sp1 == -1) ? "" : line.substring(sp1 + 1);
cmd.toLowerCase();
rest.trim();
if (cmd == "senddpt") {
int p1 = rest.indexOf(' ');
int p2 = (p1 == -1) ? -1 : rest.indexOf(' ', p1 + 1);
if (p1 == -1 || p2 == -1) { Serial.println("Syntax - senddpt <ga> <dpt> <wert>"); return; }
String ga = rest.substring(0, p1);
String dpt = rest.substring(p1 + 1, p2);
String val = rest.substring(p2 + 1);
dpt.toUpperCase();
bool ok = sendKnxValue(ga, dpt, val);
Serial.println(ok ? "OK" : "FEHLER (kein ACK)");
}
else if (cmd == "readvalue") {
if (rest.length() == 0) { Serial.println("Syntax - readvalue <ga>"); return; }
uint16_t targetGa = parseGroupAddress(rest);
bool ok = sendKnxRead(rest);
if (!ok) { Serial.println("FEHLER (kein ACK)"); return; }
unsigned long waitStart = millis();
bool gotValue = false;
while (millis() - waitStart < 1000) {
int packetSize = udp.parsePacket();
if (packetSize > 0) {
uint8_t rxBuf[64];
int len = udp.read(rxBuf, sizeof(rxBuf));
uint16_t st = processIncomingPacket(rxBuf, len);
if (st == 0x0420 && len >= 21) {
uint16_t rxGa = ((uint16_t)rxBuf[16] << 8) | rxBuf[17];
uint8_t apciType = rxBuf[20] & 0xC0;
if (rxGa == targetGa && apciType == 0x40) {
Serial.println("OK - " + interpretKnxData(&rxBuf[20], rxBuf[18]));
gotValue = true;
break;
}
}
}
delay(5);
}
if (!gotValue) Serial.println("OK (gesendet), aber keine Antwort innerhalb 1s empfangen");
}
else if (cmd == "led") {
int p1 = rest.indexOf(' ');
String sub = (p1 == -1) ? rest : rest.substring(0, p1);
String arg = (p1 == -1) ? "" : rest.substring(p1 + 1);
sub.toLowerCase();
arg.trim();
if (sub == "on") { applyOnOff(true); Serial.println("LED: AN"); }
else if (sub == "off") { applyOnOff(false); Serial.println("LED: AUS"); }
else if (sub == "next") { applyEffectStep(true); Serial.println("Programm: " + String(effectNames[activeEffect])); }
else if (sub == "prev") { applyEffectStep(false); Serial.println("Programm: " + String(effectNames[activeEffect])); }
else if (sub == "effekt" || sub == "effect") {
int e = arg.toInt();
if (arg.length() == 0 || e < 0 || e > (int)NUM_EFFECTS) { Serial.println("Syntax - led effekt <0-" + String(NUM_EFFECTS) + ">"); return; }
applyEffectSet(e);
Serial.println("Programm: " + String(effectNames[activeEffect]));
}
else if (sub == "farbe" || sub == "color") {
int c1 = arg.indexOf(','), c2 = (c1 == -1) ? -1 : arg.indexOf(',', c1 + 1);
if (c1 == -1 || c2 == -1) { Serial.println("Syntax - led farbe <r,g,b> z.B. led farbe 255,128,0"); return; }
applyColor(arg.substring(0, c1).toInt(), arg.substring(c1 + 1, c2).toInt(), arg.substring(c2 + 1).toInt());
Serial.printf("Farbe: #%02X%02X%02X\n", fxR, fxG, fxB);
}
else if (sub == "helligkeit" || sub == "bright") {
if (arg.length() == 0) { Serial.println("Syntax - led helligkeit <0-100>"); return; }
applyMasterPercent(arg.toInt());
Serial.println("Master Helligkeit - " + String(targetMaster) + "%");
}
else if (sub == "speed") {
if (arg.length() == 0) { Serial.println("Syntax: led speed <1-100>"); return; }
applySpeedPercent(arg.toInt());
Serial.println("Effekt Geschwindigkeit: " + String(effectSpeed) + "%");
}
else {
Serial.println("Unbekannter led Befehl - 'help' fuer Hilfe");
}
}
else if (cmd == "help" || cmd == "?") {
Serial.println("Befehle:");
Serial.println(" senddpt <ga> <dpt> <wert> z.B. senddpt 1/1/1 DPT1 1");
Serial.println(" readvalue <ga> z.B. readvalue 1/1/1");
Serial.println(" led on / led off Controller an/aus");
Serial.println(" led next / led prev naechstes/voriges Programm");
Serial.println(" led effekt <0-" + String(NUM_EFFECTS) + "> Programm direkt waehlen (0=manuelle Farbe)");
Serial.println(" led farbe <r,g,b> z.B. led farbe 255,128,0");
Serial.println(" led helligkeit <0-100>");
Serial.println(" led speed <1-100>");
Serial.println(" DPTs: DPT1,DPT2,DPT3,DPT5,DPT9,DPT10,DPT11,DPT14,DPT16,DPT232");
}
else {
Serial.println("Unbekannter Befehl - 'help' fuer Hilfe");
}
}
// ===============================================================================================================================
// WEBSERVER - HTML
// ===============================================================================================================================
const char INDEX_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KNX - LED Effect</title>
<style>
:root {
--bg: #0b1120;
--card: #151e2f;
--border: #334155;
--text: #f8fafc;
--muted: #94a3b8;
--red: #ef4444;
--green: #22c55e;
--blue: #3b82f6;
--primary: #6366f1;
--primary2: #818cf8;
--rainbow: linear-gradient(135deg, #ef4444, #f59e0b, #10b981, #3b82f6, #8b5cf6);
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: radial-gradient(circle at top, #1e1b4b 0%, var(--bg) 50%);
color: var(--text);
}
.container { width: 100%; max-width: 600px; margin: auto; padding: 20px 16px 40px; }
.header { text-align: center; margin-bottom: 20px; }
.header h1 { margin: 0; font-size: 26px; }
.subtitle { color: var(--muted); font-size: 14px; margin-top: 4px; }
.card {
background: rgba(21, 30, 47, 0.9); border: 1px solid var(--border);
border-radius: 18px; padding: 20px; margin-bottom: 16px;
backdrop-filter: blur(10px); box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
.card-title { font-size: 16px; font-weight: 700; margin-bottom: 15px; color: var(--primary2); display: flex; justify-content: space-between; align-items: center;}
.preview-box {
width: 100%; height: 50px; border-radius: 12px; margin-bottom: 20px;
border: 2px solid var(--border); transition: background 0.1s;
box-shadow: inset 0 2px 10px rgba(0,0,0,0.5);
}
.topbar { display:flex; flex-wrap:wrap; gap:8px; justify-content:center; margin-bottom:16px; }
.pill { background: var(--card); border:1px solid var(--border); border-radius:20px; padding:6px 14px; font-size:12.5px; color:var(--muted); }
b.ok{color:#22c55e;} b.err{color:#ef4444;}
.led-toggle { display:flex; align-items:center; justify-content:space-between; margin-bottom:16px; }
.onoff-btn { border:none; border-radius:24px; padding:10px 22px; font-weight:700; font-size:13px; cursor:pointer; background:#1e293b; color:var(--muted); }
.onoff-btn.on { background:linear-gradient(90deg,#3b82f6,#22c55e); color:#0b1120; }
.slider-group { margin-bottom: 14px; }
.slider-header { display: flex; justify-content: space-between; font-size: 14px; font-weight: 600; margin-bottom: 6px; }
.slider {
width: 100%; height: 8px; border-radius: 10px; appearance: none; outline: none;
background: #1e293b; cursor: pointer;
}
.slider::-webkit-slider-thumb {
appearance: none; width: 22px; height: 22px; border-radius: 50%;
background: #fff; border: 3px solid var(--border); cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}
.slider-master::-webkit-slider-thumb { border-color: var(--primary); }
.slider-speed::-webkit-slider-thumb { border-color: #f59e0b; }
.slider-r::-webkit-slider-thumb { border-color: var(--red); }
.slider-g::-webkit-slider-thumb { border-color: var(--green); }
.slider-b::-webkit-slider-thumb { border-color: var(--blue); }
.effects-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-top: 10px; }
@media (min-width: 480px) { .effects-grid { grid-template-columns: repeat(4, 1fr); } }
.btn-fx {
background: #1e293b; border: 1px solid var(--border); color: var(--text);
padding: 12px 8px; border-radius: 10px; font-size: 12px; font-weight: 600;
cursor: pointer; text-align: center; transition: .2s;
}
.btn-fx:hover { border-color: var(--primary); transform: translateY(-1px); }
.btn-fx.active { background: var(--primary); border-color: var(--primary); box-shadow: 0 4px 15px rgba(99,102,241,0.4); }
.btn-rainbow { border: 1px solid rgba(255,255,255,0.2); }
.btn-rainbow:hover { border-color: #ec4899; }
.btn-rainbow.active { background: var(--rainbow); border-color: #ffffff; box-shadow: 0 4px 15px rgba(236,72,153,0.5); }
.btn-stop { background: #ef4444; border: 1px solid #dc2626; color: white; padding: 8px 16px; border-radius: 8px; font-weight: bold; cursor: pointer; font-size: 12px; }
.picker-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 15px; background: #0f172a; padding: 10px 14px; border-radius: 10px; }
.color-picker { width: 60px; height: 35px; border: none; border-radius: 8px; cursor: pointer; background: none; }
.info-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid rgba(148,163,184,0.1); font-size: 13px; }
.info-row:last-child { border-bottom: none; }
.info-label { color: var(--muted); }
details { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 16px 20px; margin-top: 4px; }
summary { cursor: pointer; font-weight: 700; font-size: 13.5px; color: var(--muted); }
input[type=text], select, button.full { width:100%; padding:10px; margin:8px 0; box-sizing:border-box; border:1px solid var(--border); border-radius:8px; font-size:14px; background:#0f172a; color:var(--text); font-family:inherit; }
button.full { background:#2d6fb3; color:#fff; font-weight:700; cursor:pointer; border:none; }
#statusMsg { min-height: 18px; color: #22c55e; font-size: 13px; }
table { width:100%; border-collapse:collapse; font-size:12.5px; margin-top:8px; }
th, td { border-bottom:1px solid var(--border); padding:6px 4px; text-align:left; color:var(--muted); }
th { color: var(--text); }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>KNX - RGB Effect</h1>
<div class="subtitle">ESP32 Dynamic Lighting + KNX</div>
</div>
<div class="topbar">
<div class="pill" id="dtNow">...</div>
<div class="pill">KNX: <span id="knxStatus">...</span></div>
</div>
<div class="card">
<div id="preview" class="preview-box"></div>
<div class="led-toggle">
<span>Controller Ein/Aus</span>
<button class="onoff-btn" id="onoffBtn" onclick="toggleOnOff()">...</button>
</div>
<div class="slider-group">
<div class="slider-header">
<span>Gesamthelligkeit (Master)</span>
<span id="valMaster">100%</span>
</div>
<input type="range" id="sliderMaster" class="slider slider-master" min="0" max="100" value="100">
</div>
<div class="slider-group">
<div class="slider-header" style="color: #f59e0b;">
<span>Effekt-Geschwindigkeit (Speed)</span>
<span id="valSpeed">50%</span>
</div>
<input type="range" id="sliderSpeed" class="slider slider-speed" min="1" max="100" value="50">
</div>
</div>
<div class="card">
<div class="card-title">
<span>16 Programme (Mit Wunschfarbe)</span>
<button class="btn-stop" onclick="stopEffect()">⏹ Effekte Aus</button>
</div>
<div class="picker-row">
<span style="font-size: 13px; color: #f8fafc; font-weight: 600;">Farbe für Programme wählen:</span>
<input type="color" id="fxPicker" class="color-picker" value="#ff0000">
</div>
<div class="effects-grid">
<button class="btn-fx" id="fx1" onclick="setEffect(1)">1. Fade Pulse</button>
<button class="btn-fx" id="fx2" onclick="setEffect(2)">2. Fast Pulse</button>
<button class="btn-fx" id="fx3" onclick="setEffect(3)">3. Knight Rider</button>
<button class="btn-fx" id="fx4" onclick="setEffect(4)">4. Stroboskop</button>
<button class="btn-fx" id="fx5" onclick="setEffect(5)">5. Blitz-Strobo</button>
<button class="btn-fx" id="fx6" onclick="setEffect(6)">6. Atmung</button>
<button class="btn-fx" id="fx7" onclick="setEffect(7)">7. Kerzenflackern</button>
<button class="btn-fx" id="fx8" onclick="setEffect(8)">8. Martinshorn</button>
<button class="btn-fx" id="fx9" onclick="setEffect(9)">9. Gewitter-Blitze</button>
<button class="btn-fx" id="fx10" onclick="setEffect(10)">10. Feuerflackern</button>
<button class="btn-fx" id="fx11" onclick="setEffect(11)">11. Random Takt</button>
<button class="btn-fx" id="fx12" onclick="setEffect(12)">12. Smooth Waves</button>
<button class="btn-fx" id="fx13" onclick="setEffect(13)">13. Dimmer Cycle</button>
<button class="btn-fx" id="fx14" onclick="setEffect(14)">14. Sparkle / Funken</button>
<button class="btn-fx" id="fx15" onclick="setEffect(15)">15. Dual Flash</button>
<button class="btn-fx" id="fx16" onclick="setEffect(16)">16. Herzschlag</button>
</div>
</div>
<div class="card" style="border-color: #818cf8;">
<div class="card-title" style="color: #ec4899;">
<span>🌈 8 Regenbogen & Multi-Color</span>
</div>
<div class="effects-grid">
<button class="btn-fx btn-rainbow" id="fx17" onclick="setEffect(17)">17. Rainbow Loop</button>
<button class="btn-fx btn-rainbow" id="fx18" onclick="setEffect(18)">18. Rainbow Chase</button>
<button class="btn-fx btn-rainbow" id="fx19" onclick="setEffect(19)">19. Party Strobo</button>
<button class="btn-fx btn-rainbow" id="fx20" onclick="setEffect(20)">20. Disko Random</button>
<button class="btn-fx btn-rainbow" id="fx21" onclick="setEffect(21)">21. Aurora Ozean</button>
<button class="btn-fx btn-rainbow" id="fx22" onclick="setEffect(22)">22. Sunset Fire</button>
<button class="btn-fx btn-rainbow" id="fx23" onclick="setEffect(23)">23. Neon Party</button>
<button class="btn-fx btn-rainbow" id="fx24" onclick="setEffect(24)">24. Polizei Blau/Rot</button>
</div>
</div>
<div class="card">
<div class="card-title">Manuelle Farbwahl</div>
<div class="slider-group">
<div class="slider-header" style="color: var(--red);"><span>Rot</span><span id="valR">255</span></div>
<input type="range" id="sliderR" class="slider slider-r" min="0" max="255" value="255">
</div>
<div class="slider-group">
<div class="slider-header" style="color: var(--green);"><span>Grün</span><span id="valG">255</span></div>
<input type="range" id="sliderG" class="slider slider-g" min="0" max="255" value="255">
</div>
<div class="slider-group">
<div class="slider-header" style="color: var(--blue);"><span>Blau</span><span id="valB">255</span></div>
<input type="range" id="sliderB" class="slider slider-b" min="0" max="255" value="255">
</div>
</div>
<div class="card">
<div class="info-row">
<span class="info-label">Datum & Uhrzeit</span>
<span id="infoDatetime" style="font-weight: 600; color: var(--primary2);">Wird geladen...</span>
</div>
<div class="info-row"><span class="info-label">Status</span><span id="infoStatus">Bereit</span></div>
</div>
<details>
<summary>Erweitert - Manueller KNX Befehl & Telegrammverlauf</summary>
<label>Gruppenadresse</label><input type="text" id="ga" value="1/0/0">
<label>Datenpunkttyp (DPT)</label><select id="dpt">
<option value="DPT1">DPT 1 (1 Bit)</option>
<option value="DPT2">DPT 2 (2 Bit, C+V, 0..3)</option>
<option value="DPT3">DPT 3 (Dimmen, -7..7)</option>
<option value="DPT5">DPT 5 (1 Byte 0..255)</option>
<option value="DPT9">DPT 9 (2 Byte Float)</option>
<option value="DPT10">DPT 10 (Zeit, 14:30:00)</option>
<option value="DPT11">DPT 11 (Datum, 24.12.26)</option>
<option value="DPT14">DPT 14 (4 Byte Float)</option>
<option value="DPT16">DPT 16 (Text)</option>
<option value="DPT232">DPT 232 (RGB, z.B. 255,128,0)</option>
</select>
<label>Wert / Payload</label><input type="text" id="val" value="1">
<button class="full" onclick="sendTelegram()">KNX Telegramm Senden</button>
<button class="full" style="background:#3a3f4d;" onclick="fetch('/reconnect').then(()=>syncStatus())">KNX Reconnect</button>
<div id="statusMsg"></div>
<table><thead><tr><th>Zeit</th><th>GA</th><th>Bytes</th><th>Hex</th><th>Wert</th></tr></thead>
<tbody id="logBody"><tr><td colspan="5">Lade...</td></tr></tbody></table>
</details>
</div>
<script>
const sM = document.getElementById("sliderMaster");
const sSp = document.getElementById("sliderSpeed");
const sR = document.getElementById("sliderR");
const sG = document.getElementById("sliderG");
const sB = document.getElementById("sliderB");
const fxPicker = document.getElementById("fxPicker");
const preview = document.getElementById("preview");
const infoDatetime = document.getElementById("infoDatetime");
const onoffBtn = document.getElementById("onoffBtn");
let sendTimer;
let currentServerTime = null;
let isUserInteracting = false;
let currentActiveFx = 0;
let ledIsOn = true;
[sM, sSp, sR, sG, sB].forEach(el => {
el.addEventListener("mousedown", () => isUserInteracting = true);
el.addEventListener("touchstart", () => isUserInteracting = true);
el.addEventListener("mouseup", () => isUserInteracting = false);
el.addEventListener("touchend", () => isUserInteracting = false);
});
sM.addEventListener("input", onSliderChange);
sR.addEventListener("input", onRGBColorSliderChange);
sG.addEventListener("input", onRGBColorSliderChange);
sB.addEventListener("input", onRGBColorSliderChange);
sSp.addEventListener("input", onSpeedSliderChange);
fxPicker.addEventListener("input", function() {
const hex = this.value;
const r = parseInt(hex.substr(1,2), 16);
const g = parseInt(hex.substr(3,2), 16);
const b = parseInt(hex.substr(5,2), 16);
sR.value = r; sG.value = g; sB.value = b;
updateUI();
fetch(`/api/fxcolor?r=${r}&g=${g}&b=${b}`).catch(e => console.log(e));
});
function onRGBColorSliderChange() {
if (currentActiveFx !== 0) {
stopEffect();
}
onSliderChange();
}
function onSpeedSliderChange() {
updateUI();
clearTimeout(sendTimer);
sendTimer = setTimeout(() => {
fetch(`/api/speed?val=${sSp.value}`).catch(e => console.log(e));
}, 40);
}
function onSliderChange() {
updateUI();
clearTimeout(sendTimer);
sendTimer = setTimeout(sendData, 40);
}
function toggleOnOff() {
ledIsOn = !ledIsOn;
applyOnOffUI();
fetch(`/api/onoff?val=${ledIsOn ? 1 : 0}`).catch(e => console.log(e));
}
function applyOnOffUI() {
onoffBtn.classList.toggle("on", ledIsOn);
onoffBtn.innerText = ledIsOn ? "AN" : "AUS";
}
function setEffect(fxId) {
currentActiveFx = fxId;
highlightFxButton(fxId);
fetch(`/api/effect?id=${fxId}&speed=${sSp.value}`).catch(e => console.log(e));
}
function stopEffect() {
currentActiveFx = 0;
highlightFxButton(0);
fetch(`/api/effect?id=0&speed=${sSp.value}`).catch(e => console.log(e));
}
function highlightFxButton(fxId) {
for (let i = 1; i <= 24; i++) {
const btn = document.getElementById(`fx${i}`);
if (btn) {
if (i === fxId) btn.classList.add("active");
else btn.classList.remove("active");
}
}
}
function updateUI() {
document.getElementById("valMaster").innerText = sM.value + "%";
document.getElementById("valSpeed").innerText = sSp.value + "%";
document.getElementById("valR").innerText = sR.value;
document.getElementById("valG").innerText = sG.value;
document.getElementById("valB").innerText = sB.value;
const factor = sM.value / 100;
const r = Math.round(sR.value * factor);
const g = Math.round(sG.value * factor);
const b = Math.round(sB.value * factor);
preview.style.background = `rgb(${r}, ${g}, ${b})`;
preview.style.boxShadow = `0 0 20px rgb(${r}, ${g}, ${b})`;
}
function sendData() {
const url = `/api/rgb?r=${sR.value}&g=${sG.value}&b=${sB.value}&m=${sM.value}&speed=${sSp.value}`;
fetch(url).catch(e => console.log(e));
}
function updateClockLocally() {
if (!currentServerTime) return;
currentServerTime.setSeconds(currentServerTime.getSeconds() + 1);
const d = currentServerTime;
const pad = n => String(n).padStart(2, '0');
infoDatetime.innerText = `${pad(d.getDate())}.${pad(d.getMonth()+1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function flash(t) {
const el = document.getElementById('statusMsg');
if (el) { el.innerText = t; clearTimeout(window._st); window._st = setTimeout(() => el.innerText = '', 2500); }
}
function sendTelegram() {
const ga = document.getElementById('ga').value;
const dpt = document.getElementById('dpt').value;
const val = document.getElementById('val').value;
fetch('/send?ga=' + encodeURIComponent(ga) + '&dpt=' + encodeURIComponent(dpt) + '&val=' + encodeURIComponent(val))
.then(r => r.text()).then(flash).catch(() => {});
}
function syncStatus() {
fetch("/api/status")
.then(res => res.json())
.then(data => {
document.getElementById('dtNow').innerText = data.datetime;
document.getElementById('knxStatus').innerHTML = data.knxConnected
? "<b class='ok'>Verbunden (Ch " + data.knxChannel + ")</b>"
: "<b class='err'>Getrennt</b>";
ledIsOn = data.on;
applyOnOffUI();
if (!isUserInteracting) {
sR.value = data.r; sG.value = data.g; sB.value = data.b;
sM.value = data.m; sSp.value = data.speed;
}
currentActiveFx = data.fx;
highlightFxButton(data.fx);
updateUI();
if (data.epoch > 0) currentServerTime = new Date(data.epoch * 1000);
var rows = '';
(data.log || []).forEach(function(e) {
rows += '<tr><td>' + e.time + '</td><td>' + e.ga + '</td><td>' + e.len + '</td><td>' + e.hex + '</td><td>' + e.value + '</td></tr>';
});
document.getElementById('logBody').innerHTML = rows || '<tr><td colspan="5">Noch keine Telegramme empfangen</td></tr>';
}).catch(e => console.log(e));
}
syncStatus();
setInterval(updateClockLocally, 1000);
setInterval(syncStatus, 1000);
</script>
</body>
</html>
)rawliteral";
// ===============================================================================================================================
// WEBSERVER ROUTEN
// ===============================================================================================================================
String escapeJson(String s) {
String out;
for (size_t i = 0; i < s.length(); i++) {
char c = s[i];
if (c == '"' || c == '\\') out += '\\';
out += c;
}
return out;
}
void handleRoot() {
server.send_P(200, "text/html; charset=utf-8", INDEX_HTML);
}
void handleRGB() {
if (server.hasArg("r") && server.hasArg("g") && server.hasArg("b") && server.hasArg("m")) {
int r = server.arg("r").toInt();
int g = server.arg("g").toInt();
int b = server.arg("b").toInt();
int m = server.arg("m").toInt();
applyEffectSet(0); // manuelle Farbe -> Effekt aus
applyMasterPercent(m);
applyColor(r, g, b);
if (server.hasArg("speed")) applySpeedPercent(server.arg("speed").toInt());
server.send(200, "application/json", "{\"success\":true}");
} else {
server.send(400, "text/plain", "Fehlende Parameter");
}
}
void handleSpeed() {
if (server.hasArg("val")) {
applySpeedPercent(server.arg("val").toInt());
server.send(200, "application/json", "{\"success\":true}");
} else {
server.send(400, "text/plain", "Parameter fehlt");
}
}
void handleEffect() {
if (server.hasArg("id")) {
applyEffectSet(server.arg("id").toInt());
if (server.hasArg("speed")) applySpeedPercent(server.arg("speed").toInt());
server.send(200, "application/json", "{\"success\":true}");
} else {
server.send(400, "text/plain", "Parameter fehlt");
}
}
void handleFxColor() {
if (server.hasArg("r") && server.hasArg("g") && server.hasArg("b")) {
applyColor(server.arg("r").toInt(), server.arg("g").toInt(), server.arg("b").toInt());
server.send(200, "application/json", "{\"success\":true}");
} else {
server.send(400, "text/plain", "Parameter fehlt");
}
}
void handleOnOff() {
if (server.hasArg("val")) {
applyOnOff(server.arg("val").toInt() > 0);
server.send(200, "application/json", "{\"success\":true}");
} else {
server.send(400, "text/plain", "Parameter fehlt");
}
}
void handleSend() {
if (server.hasArg("ga") && server.hasArg("dpt") && server.hasArg("val")) {
String ga = server.arg("ga");
String dpt = server.arg("dpt");
String val = server.arg("val");
bool ok = sendKnxValue(ga, dpt, val);
server.send(200, "text/plain", ok ? "Telegramm gesendet und per ACK bestaetigt!" : "Gesendet, aber KEIN ACK erhalten - siehe Serial-Monitor!");
} else {
server.send(400, "text/plain", "Fehlende Parameter!");
}
}
void handleReconnect() {
connectKnx();
server.send(200, "text/plain", "Reconnected!");
}
void handleStatus() {
String json = "{";
json += "\"project\":\"" + escapeJson(String(projectName)) + "\",";
json += "\"datetime\":\"" + escapeJson(getFormattedDateTime()) + "\",";
json += "\"knxConnected\":" + String(isKnxConnected ? "true" : "false") + ",";
json += "\"knxChannel\":" + String(knxChannelId) + ",";
json += "\"on\":" + String(ledOn ? "true" : "false") + ",";
json += "\"r\":" + String(targetR);
json += ",\"g\":" + String(targetG);
json += ",\"b\":" + String(targetB);
json += ",\"m\":" + String(targetMaster);
json += ",\"speed\":" + String(effectSpeed);
json += ",\"fx\":" + String(activeEffect);
json += ",\"colorTemp\":" + String(colorTempPercent);
json += ",\"hue\":" + String(hueDegrees);
json += ",\"epoch\":" + String(getEpochTime());
json += ",\"log\":[";
for (int i = 0; i < knxLogFilled; i++) {
int idx = (knxLogNext - 1 - i + KNX_LOG_SIZE) % KNX_LOG_SIZE;
KnxLogEntry &e = knxLog[idx];
String hex = "";
for (int b = 0; b < e.len; b++) {
char hb[4];
snprintf(hb, sizeof(hb), "%02X ", e.data[b]);
hex += hb;
}
hex.trim();
if (i > 0) json += ",";
json += "{";
json += "\"time\":\"" + escapeJson(String(e.timeStr)) + "\",";
json += "\"ga\":\"" + escapeJson(gaToString(e.ga)) + "\",";
json += "\"len\":" + String(e.len) + ",";
json += "\"hex\":\"" + escapeJson(hex) + "\",";
json += "\"value\":\"" + escapeJson(interpretKnxData(e.data, e.len)) + "\"";
json += "}";
}
json += "]}";
server.send(200, "application/json", json);
}
// ===============================================================================================================================
// WLAN
// ===============================================================================================================================
void connectWiFi() {
WiFi.mode(WIFI_STA);
WiFi.setHostname(HOSTNAME);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
Serial.print("Verbinde WLAN");
unsigned long start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < 15000) {
delay(500);
Serial.print(".");
}
Serial.println();
if (WiFi.status() == WL_CONNECTED) {
Serial.print("IP - "); Serial.println(WiFi.localIP());
}
}
// ===============================================================================================================================
// SETUP / LOOP
// ===============================================================================================================================
void setup() {
Serial.begin(115200);
delay(200);
Serial.printf("\n===> %s <===\n", projectName);
preferences.begin("rgb-dimmer", false);
loadSettings();
ledcAttach(PIN_RED, PWM_FREQUENCY, PWM_RESOLUTION);
ledcAttach(PIN_GREEN, PWM_FREQUENCY, PWM_RESOLUTION);
ledcAttach(PIN_BLUE, PWM_FREQUENCY, PWM_RESOLUTION);
writePWMHardware(ledOn ? currentR : 0, ledOn ? currentG : 0, ledOn ? currentB : 0);
gaLedOnOffParsed = parseGroupAddress(gaLedOnOff);
gaEffectStepParsed = parseGroupAddress(gaEffectStep);
gaEffectSetParsed = parseGroupAddress(gaEffectSet);
gaColorRGBParsed = parseGroupAddress(gaColorRGB);
gaBrightnessParsed = parseGroupAddress(gaBrightness);
gaSpeedParsed = parseGroupAddress(gaSpeed);
gaColorTempStepParsed = parseGroupAddress(gaColorTempStep);
gaHueStepParsed = parseGroupAddress(gaHueStep);
connectWiFi();
if (WiFi.status() == WL_CONNECTED) {
configTzTime(TZ_INFO, NTP_SERVER);
Serial.println("Warte auf Zeitsynchronisation (NTP)...");
struct tm timeinfo;
if (getLocalTime(&timeinfo, 5000)) {
Serial.println("Zeit synchronisiert: " + getFormattedDateTime());
} else {
Serial.println("Zeit konnte nicht synchronisiert werden (wird spaeter automatisch nachgeholt).");
}
if (MDNS.begin(HOSTNAME)) MDNS.addService("http", "tcp", 80);
}
udp.begin(localPort);
connectKnx(true);
server.on("/", HTTP_GET, handleRoot);
server.on("/api/rgb", HTTP_GET, handleRGB);
server.on("/api/speed", HTTP_GET, handleSpeed);
server.on("/api/effect", HTTP_GET, handleEffect);
server.on("/api/fxcolor", HTTP_GET, handleFxColor);
server.on("/api/onoff", HTTP_GET, handleOnOff);
server.on("/api/status", HTTP_GET, handleStatus);
server.on("/send", HTTP_GET, handleSend);
server.on("/reconnect", HTTP_GET, handleReconnect);
server.begin();
Serial.println("Webserver gestartet. 'help' fuer Serial-Befehle.");
}
void loop() {
server.handleClient();
handleKnxRx();
handleSerialCommands();
if (!ledOn) {
if (currentR != 0 || currentG != 0 || currentB != 0) {
currentR = currentG = currentB = 0;
isFading = false;
writePWMHardware(0, 0, 0);
}
} else {
processEffects();
updateFade();
processColorTempDim();
processHueDim();
}
if (isKnxConnected) {
if (millis() - lastHeartbeat > HEARTBEAT_INTERVAL) {
sendConnectionStateRequest();
lastHeartbeat = millis();
}
} else {
static unsigned long lastReconnectAttempt = 0;
if (millis() - lastReconnectAttempt > 3000) {
connectKnx();
lastReconnectAttempt = millis();
}
}
}