Note de ce sujet :
  • Moyenne : 0 (0 vote(s))
  • 1
  • 2
  • 3
  • 4
  • 5
KNX Stack - C++ Code ESP32 pour l'IDE Arduino
#1
Bonjour à tous,

Je souhaite mettre à votre disposition ce code qui permet de communiquer avec KNX sur un ESP32 sans aucune bibliothèque supplémentaire. Il peut aussi bien envoyer que recevoir les télégrammes les plus courants.

Code :
#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();
  }
}
Répondre
#2
je teste des que j'ai un peu de temps
Répondre
#3
(15/09/2026, 21:32:15)richardpub a écrit : je teste des que j'ai un peu de temps

Je dispose du code en plusieurs versions et je vous les fournirai prochainement.
Répondre
#4
Salut,
j'ai testé le code KNX avec un bandeau LED RGB 24V. Le bandeau est branché en 24V et GND avec des MOSFETs et ça marche super bien, tout comme le contrôle depuis le serveur web.

En raison de la longueur du code, je le publie en deux parties. 

Code :
#include <WiFi.h>
#include <WiFiUdp.h>
#include <WebServer.h>
#include <Preferences.h>
#include <ESPmDNS.h>
#include <time.h>

const char* projectName   = "KNX RGB Controller";

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";

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;

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;

bool ledOn = true;                                              // Ruban LED marche/arrêt global

int targetR = 255, targetG = 255, targetB = 255;
int targetMaster = 100;                                         // Luminosité principale en % (0-100)

int activeEffect = 0;                                           // 0 = couleur manuelle, 1-24 = programmes
int effectSpeed  = 50;                                          // 1-100 (interne), l'adresse de groupe KNX utilise 0-255
int fxR = 255, fxG = 0, fxB = 0;                                // Couleur pour les programmes

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%  (Changement de température de couleur/blanc, terrasse)
int hueDegrees        = 0;                                      // 0-359°  (Changement de teinte HSV, terrasse)

const uint8_t NUM_EFFECTS = 24;                                 // Programmes 1-24 (0 = couleur manuelle)
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"
};

const char* gaLedOnOff    = "1/0/1";                            // DPT1         - 0=Éteint, 1=Allumé
const char* gaEffectStep  = "3/0/0";                            // DPT1         - 0=Programme précédent, 1=Programme suivant
const char* gaEffectSet   = "3/0/2";                            // DPT5         - Définir directement le programme (0-24)
const char* gaColorRGB    = "3/0/3";                            // DPT232.600   - Couleur RGB 3 octets
const char* gaBrightness  = "3/0/4";                            // DPT5         - Luminosité principale 0-255 (-> 0-100%)
const char* gaSpeed       = "3/0/5";                            // DPT5         - Vitesse de l'effet 0-255 (-> 1-100%)

const char* gaColorTempStep   = "1/6/5";                        // DPT3 (4 Bit) - Modifier la température de couleur/blanc relativement (Bouton)
const char* gaColorTempStatus = "10/0/27";                      // DPT5.001     - État température de couleur, pourcentage 0-100%
const char* gaHueStep         = "1/6/6";                        // DPT3 (4 Bit) - Modifier la teinte HSV (H) relativement (Bouton)
const char* gaHueStatus       = "10/0/28";                      // DPT5.003     - État teinte (H), angle 0-360°

uint16_t gaLedOnOffParsed, gaEffectStepParsed, gaEffectSetParsed, gaColorRGBParsed, gaBrightnessParsed, gaSpeedParsed;
uint16_t gaColorTempStepParsed, gaHueStepParsed;

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++;
}

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);
  }
}

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;
  }
}

bool sendKnxValue(String groupAddress, String dptType, String rawValue);

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)));
}

bool colorTempDimActive = false;
bool colorTempDimUp     = false;
unsigned long colorTempLastStep = 0;
unsigned long colorTempLastKnxSend = 0;
const unsigned long COLORTEMP_STEP_INTERVAL_MS = 40;
const unsigned long DIM_STATUS_INTERVAL_MS     = 300;

bool hueDimActive = false;
bool hueDimUp     = false;
unsigned long hueLastStep = 0;
unsigned long hueLastKnxSend = 0;
const unsigned long HUE_STEP_INTERVAL_MS = 15;

void updateColorTempColor() {
  float t = colorTempPercent / 100.0f;
  int r = round(255 + (200 - 255) * t);
  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);
  }
}

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));
}

void startColorTempDim(bool up) {
  colorTempDimActive = true;
  colorTempDimUp = up;
  colorTempLastStep = millis();
  colorTempLastKnxSend = millis();
}

void stopColorTempDim() {
  if (!colorTempDimActive) return;
  colorTempDimActive = false;
  sendColorTempStatus();
}

void startHueDim(bool up) {
  hueDimActive = true;
  hueDimUp = up;
  hueLastStep = millis();
  hueLastKnxSend = millis();
}

void stopHueDim() {
  if (!hueDimActive) return;
  hueDimActive = false;
  sendHueStatus();
}

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;
    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;
  }
}

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();
    else                startColorTempDim((raw & 0x08) != 0);
  }
  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);
  }
}

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;
}

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");
  }
}
Répondre
#5
Voici la deuxième partie du code. 

Code :
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";

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);
    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);
}

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());
  }
}

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();
    }
  }
}
Répondre


Atteindre :


Utilisateur(s) parcourant ce sujet : 1 visiteur(s)