16/09/2026, 20:45:43
Bonjour,
j'ai enrichi le code avec des DPT supplémentaires pour l'envoi ainsi qu'une fonction Heartbeat. Le script fonctionne désormais comme un moniteur de bus, affichant tous les télégrammes entrants avec leurs valeurs brutes.
j'ai enrichi le code avec des DPT supplémentaires pour l'envoi ainsi qu'une fonction Heartbeat. Le script fonctionne désormais comme un moniteur de bus, affichant tous les télégrammes entrants avec leurs valeurs brutes.
Code PHP :
<?php
class KNXTunnel {
private string $ip;
private int $port;
private $socket = null;
private int $channelId = 0;
private int $sequenceNumber = 0;
private string $localIp = '';
private int $localPort = 0;
private array $logEntries = [];
private array $telegramQueue = [];
public function __construct(string $ip, int $port = 3671) {
$this->ip = $ip;
$this->port = $port;
}
private function log(string $msg, string $type = 'info'): void {
$this->logEntries[] = [
'type' => $type,
'msg' => $msg,
'time' => round(microtime(true) * 1000),
];
}
public function logError(string $msg): void {
$this->log($msg, 'error');
}
public function getLog(): array {
return $this->logEntries;
}
public function clearLog(): void {
$this->logEntries = [];
}
public function getTelegramQueue(): array {
return $this->telegramQueue;
}
public function clearTelegramQueue(): void {
$this->telegramQueue = [];
}
private function hexDump(string $data): string {
return implode(' ', str_split(bin2hex($data), 2));
}
private function getLocalIpFor(string $targetIp): string {
$sock = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
@socket_connect($sock, $targetIp, $this->port);
socket_getsockname($sock, $addr);
socket_close($sock);
return $addr;
}
private function statusText(int $status): string {
return match ($status) {
0x00 => 'E_NO_ERROR',
0x22 => 'E_CONNECTION_TYPE (Verbindungstyp nicht unterstützt)',
0x23 => 'E_CONNECTION_OPTION (Option nicht unterstützt)',
0x24 => 'E_NO_MORE_CONNECTIONS (max. Tunnel-Verbindungen erreicht)',
0x26 => 'E_DATA_CONNECTION',
0x27 => 'E_KNX_CONNECTION',
0x29 => 'E_NO_MORE_UNIQUE_CONNECTIONS',
default => 'unbekannt (0x' . sprintf('%02X', $status) . ')',
};
}
public function isConnected(): bool {
return $this->channelId !== 0;
}
public function getChannelId(): int {
return $this->channelId;
}
public function connect(): bool {
$this->socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
if (!$this->socket) {
$this->log('Socket konnte nicht erstellt werden: ' . socket_strerror(socket_last_error()), 'error');
return false;
}
socket_set_option($this->socket, SOL_SOCKET, SO_RCVTIMEO, ['sec' => 3, 'usec' => 0]);
$myPcIp = $this->getLocalIpFor($this->ip);
$this->log("Verwende lokale IP: {$myPcIp}", 'info');
if (!@socket_bind($this->socket, $myPcIp, 0)) {
$this->log("Fehler beim Binden an {$myPcIp}: " . socket_strerror(socket_last_error($this->socket)), 'error');
return false;
}
$localPort = 0;
socket_getsockname($this->socket, $myPcIp, $localPort);
$this->localIp = $myPcIp;
$this->localPort = $localPort;
$ipParts = array_map('intval', explode('.', $myPcIp));
$hpai = pack('C*', 0x08, 0x01, $ipParts[0], $ipParts[1], $ipParts[2], $ipParts[3]) . pack('n', $localPort);
$header = "\x06\x10\x02\x05\x00\x1a";
$cri = "\x04\x04\x02\x00";
$connectRequest = $header . $hpai . $hpai . $cri;
socket_sendto($this->socket, $connectRequest, strlen($connectRequest), 0, $this->ip, $this->port);
$this->log("CONNECT_REQUEST an {$this->ip}:{$this->port} gesendet (" . strlen($connectRequest) . " Bytes)", 'send');
$this->log($this->hexDump($connectRequest), 'send-raw');
$buffer = '';
$from = '';
$fromPort = 0;
$bytes = @socket_recvfrom($this->socket, $buffer, 1024, 0, $from, $fromPort);
if ($bytes === false || $bytes < 8) {
$this->log('Keine gültige Antwort erhalten: ' . socket_strerror(socket_last_error($this->socket)), 'error');
return false;
}
$this->log("Antwort empfangen von {$from}:{$fromPort} ({$bytes} Bytes)", 'recv');
$this->log($this->hexDump($buffer), 'recv-raw');
$serviceType = (ord($buffer[2]) << 8) | ord($buffer[3]);
if ($serviceType !== 0x0206) {
$this->log('Unerwarteter Service Type: 0x' . sprintf('%04X', $serviceType), 'error');
return false;
}
$status = ord($buffer[7]);
if ($status !== 0x00) {
$this->log('Gateway lehnt ab, Status: ' . $this->statusText($status), 'error');
return false;
}
$this->channelId = ord($buffer[6]);
$this->sequenceNumber = 0;
socket_set_option($this->socket, SOL_SOCKET, SO_RCVTIMEO, ['sec' => 0, 'usec' => 250000]);
$this->log("Tunnel-Verbindung aufgebaut (Channel ID: {$this->channelId})", 'success');
return true;
}
public function disconnect(): void {
if ($this->channelId !== 0 && $this->socket) {
$ipParts = array_map('intval', explode('.', $this->localIp ?: '0.0.0.0'));
$hpai = pack('C*', 0x08, 0x01, $ipParts[0], $ipParts[1], $ipParts[2], $ipParts[3]) . pack('n', $this->localPort);
$req = "\x06\x10\x02\x09\x00\x10" . pack('CC', $this->channelId, 0x00) . $hpai;
@socket_sendto($this->socket, $req, strlen($req), 0, $this->ip, $this->port);
$this->log('DISCONNECT_REQUEST gesendet (Channel ' . $this->channelId . ')', 'send');
$this->log($this->hexDump($req), 'send-raw');
$this->log('Tunnel-Verbindung getrennt.', 'info');
$this->channelId = 0;
}
if ($this->socket) {
@socket_close($this->socket);
$this->socket = null;
}
}
private function sendHeartbeat(): void {
if (!$this->socket || $this->channelId === 0) return;
$ipParts = array_map('intval', explode('.', $this->localIp ?: '0.0.0.0'));
$hpai = pack('C*', 0x08, 0x01, $ipParts[0], $ipParts[1], $ipParts[2], $ipParts[3]) . pack('n', $this->localPort);
$req = "\x06\x10\x02\x07\x00\x10" . pack('CC', $this->channelId, 0x00) . $hpai;
@socket_sendto($this->socket, $req, strlen($req), 0, $this->ip, $this->port);
$this->log('Heartbeat (CONNECTIONSTATE_REQUEST) gesendet.', 'info');
$this->log($this->hexDump($req), 'send-raw');
}
public function pollIncoming(): void {
if (!$this->socket) return;
$buffer = '';
$from = '';
$fromPort = 0;
$bytes = @socket_recvfrom($this->socket, $buffer, 1024, 0, $from, $fromPort);
if ($bytes === false || $bytes < 8) return;
$this->handleIncomingPacket($buffer, $bytes);
}
private function handleIncomingPacket(string $buffer, int $bytes): void {
$serviceType = (ord($buffer[2]) << 8) | ord($buffer[3]);
if ($serviceType === 0x0420) {
$this->log("Telegramm empfangen ({$bytes} Bytes)", 'recv');
$this->log($this->hexDump($buffer), 'recv-raw');
$seqOfRequest = ord($buffer[8]);
$this->sendAck($seqOfRequest);
$cemi = substr($buffer, 10);
try {
$decoded = $this->decodeCemi($cemi);
$decoded['raw'] = $this->hexDump($buffer);
$this->telegramQueue[] = $decoded;
try {
knx_store_telegram($decoded);
} catch (\Throwable $e) {
$this->log('Telegramm konnte nicht in der Datenbank gespeichert werden: ' . $e->getMessage(), 'warn');
}
} catch (\Throwable $e) {
$this->log('Telegramm konnte nicht dekodiert werden : ' . $e->getMessage(), 'warn');
}
} elseif ($serviceType === 0x0421) {
$this->log("ACK empfangen ({$bytes} Bytes)", 'recv');
$this->log($this->hexDump($buffer), 'recv-raw');
} elseif ($serviceType === 0x0208) {
$status = $bytes >= 8 ? ord($buffer[7]) : -1;
if ($status === 0x00) {
$this->log('Heartbeat Antwort OK.', 'info');
} else {
$this->log('Heartbeat Antwort mit Fehlerstatus: ' . $this->statusText($status), 'warn');
}
} else {
$this->log('Unbekanntes Paket empfangen (Service 0x' . sprintf('%04X', $serviceType) . ', ' . $bytes . ' Bytes)', 'recv');
$this->log($this->hexDump($buffer), 'recv-raw');
}
}
private function sendAck(int $seq): void {
if (!$this->socket) return;
$ack = "\x06\x10\x04\x21\x00\x0a" . pack('CC', 0x04, $this->channelId) . pack('CC', $seq, 0x00);
@socket_sendto($this->socket, $ack, strlen($ack), 0, $this->ip, $this->port);
$this->log('TUNNELING_ACK gesendet (Seq ' . $seq . ')', 'send');
$this->log($this->hexDump($ack), 'send-raw');
}
public function maintainHeartbeat(int &$lastHeartbeat): void {
if (time() - $lastHeartbeat >= 60) {
$this->sendHeartbeat();
$lastHeartbeat = time();
}
}
private function parseGroupAddress(string $ga): string {
$parts = explode('/', $ga);
if (count($parts) === 3) {
$main = (int)$parts[0] & 0x1F;
$middle = (int)$parts[1] & 0x07;
$sub = (int)$parts[2] & 0xFF;
$val = ($main << 11) | ($middle << 8) | $sub;
return pack('n', $val);
}
throw new Exception("Ungültige Gruppenadresse '{$ga}' (erwartet : haupt/mittel/unter, z.B. 1/2/10)");
}
private function formatGroupAddress(int $addr): string {
$main = ($addr >> 11) & 0x1F;
$middle = ($addr >> 8) & 0x07;
$sub = $addr & 0xFF;
return "{$main}/{$middle}/{$sub}";
}
private function formatIndividualAddress(int $addr): string {
$area = ($addr >> 12) & 0x0F;
$line = ($addr >> 8) & 0x0F;
$device = $addr & 0xFF;
return "{$area}.{$line}.{$device}";
}
private function toBool($v): bool {
return in_array(strtolower(trim((string)$v)), ['1', 'true', 'ein', 'on', 'ja'], true);
}
private function splitPair($value, int $d1, int $d2): array {
$parts = array_map('intval', explode(',', (string)$value));
return [$parts[0] ?? $d1, $parts[1] ?? $d2];
}
private function encodeDpt9Float(float $v): string {
$sign = $v < 0 ? 0x8000 : 0x0000;
$av = abs($v);
$mant = (int)round($av * 100);
$exp = 0;
while ($mant > 2047) {
$mant >>= 1;
$exp++;
}
if ($sign) {
$mant = (-$mant) & 0x07FF;
}
$raw = $sign | (($exp & 0x0F) << 11) | ($mant & 0x07FF);
return pack('n', $raw);
}
private function encodeDPT(string $dpt, $value): string {
$dpt = strtoupper(trim($dpt));
switch ($dpt) {
case 'DPT1':
return pack('C', 0x80 | ($this->toBool($value) ? 1 : 0));
case 'DPT2':
[$c, $b] = $this->splitPair($value, 0, 0);
return pack('C', 0x80 | (($c & 1) << 1) | ($b & 1));
case 'DPT3':
[$c, $s] = $this->splitPair($value, 0, 0);
return pack('C', 0x80 | (($c & 1) << 3) | ($s & 0x07));
case 'DPT4':
$ch = is_string($value) && $value !== '' && !ctype_digit($value) ? ord($value[0]) : ((int)$value & 0xFF);
return pack('CC', 0x80, $ch);
case 'DPT5':
return pack('CC', 0x80, max(0, min(255, (int)$value)));
case 'DPT6':
return pack('C', 0x80) . pack('c', max(-128, min(127, (int)$value)));
case 'DPT7':
return pack('C', 0x80) . pack('n', max(0, min(65535, (int)$value)));
case 'DPT8':
$v = max(-32768, min(32767, (int)$value));
return pack('C', 0x80) . pack('n', $v & 0xFFFF);
case 'DPT9':
return pack('C', 0x80) . $this->encodeDpt9Float((float)$value);
case 'DPT10':
$str = (string)$value;
$day = 0;
$timePart = $str;
if (str_contains($str, ',')) {
[$dayStr, $timePart] = explode(',', $str, 2);
$day = (int)$dayStr;
}
$tp = array_map('intval', explode(':', trim($timePart)));
$h = $tp[0] ?? 0;
$mi = $tp[1] ?? 0;
$se = $tp[2] ?? 0;
$b1 = (($day & 0x07) << 5) | ($h & 0x1F);
return pack('C', 0x80) . pack('C*', $b1, $mi & 0x3F, $se & 0x3F);
case 'DPT11':
$str = (string)$value;
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $str, $m)) {
$y = (int)$m[1]; $mo = (int)$m[2]; $d = (int)$m[3];
} elseif (preg_match('/^(\d{2})\.(\d{2})\.(\d{4})$/', $str, $m)) {
$d = (int)$m[1]; $mo = (int)$m[2]; $y = (int)$m[3];
} else {
throw new Exception('DPT11 erwartet ein Datum im Format YYYY-MM-DD');
}
$yy = $y % 100;
return pack('C', 0x80) . pack('C*', $d & 0x1F, $mo & 0x0F, $yy & 0x7F);
case 'DPT13':
$v = (int)$value;
return pack('C', 0x80) . pack('N', $v & 0xFFFFFFFF);
case 'DPT14':
return pack('C', 0x80) . pack('G', (float)$value);
case 'DPT16':
$s = substr((string)$value, 0, 14);
return pack('C', 0x80) . str_pad($s, 14, "\0");
case 'DPT17':
return pack('C', 0x80 | (max(0, min(63, (int)$value)) & 0x3F));
case 'DPT20':
return pack('CC', 0x80, max(0, min(255, (int)$value)));
case 'DPT232':
$parts = array_map('intval', explode(',', (string)$value));
$r = max(0, min(255, $parts[0] ?? 0));
$g = max(0, min(255, $parts[1] ?? 0));
$b = max(0, min(255, $parts[2] ?? 0));
return pack('C', 0x80) . pack('C*', $r, $g, $b);
default:
throw new Exception("DPT '{$dpt}' wird nicht unterstützt.");
}
}
public function send(string $groupAddress, string $dptType, $value): bool {
if ($this->channelId === 0 || !$this->socket) {
$this->log('send() ohne aktiven Tunnel aufgerufen.', 'error');
return false;
}
try {
$gaBytes = $this->parseGroupAddress($groupAddress);
$payload = $this->encodeDPT($dptType, $value);
} catch (\Throwable $e) {
$this->log('Fehler beim Kodieren : ' . $e->getMessage(), 'error');
return false;
}
$cemi = "\x11\x00\xbc\xe0\x00\x00" . $gaBytes . pack('C', strlen($payload)) . "\x00" . $payload;
$totalLen = 6 + 4 + strlen($cemi);
$header = "\x06\x10\x04\x20" . pack('n', $totalLen);
$mySeq = $this->sequenceNumber;
$connectionHeader = pack('CC', 0x04, $this->channelId) . pack('CC', $mySeq, 0x00);
$packet = $header . $connectionHeader . $cemi;
socket_sendto($this->socket, $packet, strlen($packet), 0, $this->ip, $this->port);
$this->log("Telegramm an {$groupAddress} ({$dptType}) gesendet (Seq {$mySeq})", 'send');
$this->log($this->hexDump($packet), 'send-raw');
$ok = $this->waitForAck($mySeq, 2.0);
$this->sequenceNumber = ($this->sequenceNumber + 1) % 256;
return $ok;
}
private function waitForAck(int $expectedSeq, float $timeoutSec): bool {
$deadline = microtime(true) + $timeoutSec;
while (microtime(true) < $deadline) {
$buffer = '';
$from = '';
$fromPort = 0;
$bytes = @socket_recvfrom($this->socket, $buffer, 1024, 0, $from, $fromPort);
if ($bytes === false || $bytes < 8) {
continue;
}
$serviceType = (ord($buffer[2]) << 8) | ord($buffer[3]);
if ($serviceType === 0x0421 && $bytes >= 10) {
$ackSeq = ord($buffer[8]);
$status = ord($buffer[9]);
$this->log("ACK empfangen (Seq {$ackSeq}, {$bytes} Bytes)", 'recv');
$this->log($this->hexDump($buffer), 'recv-raw');
if ($status === 0x00) {
$this->log('ACK vom Gateway erhalten.', 'success');
return true;
}
$this->log('ACK mit Fehlerstatus: 0x' . sprintf('%02X', $status), 'warn');
return false;
}
$this->handleIncomingPacket($buffer, $bytes);
}
$this->log('Timeout: kein ACK innerhalb von ' . $timeoutSec . 's erhalten.', 'error');
return false;
}
private function messageCodeText(int $mc): string {
return match ($mc) {
0x11 => 'L_Data.req',
0x29 => 'L_Data.ind',
0x2E => 'L_Data.con',
0x2B => 'L_Busmon.ind',
0xFB => 'Secure Wrapper',
default => sprintf('0x%02X', $mc),
};
}
private function guessValue(string $bytes): string {
$len = strlen($bytes);
$hexLabel = $len > 0 ? ('0x' . strtoupper(bin2hex($bytes))) : '(leer)';
if ($len === 0) return "{$hexLabel} · kein Datenanteil";
if ($len === 1) {
$v = ord($bytes[0]);
return "{$hexLabel} · {$v}";
}
if ($len === 2) {
$u = (ord($bytes[0]) << 8) | ord($bytes[1]);
$exp = ($u >> 11) & 0x0F;
$mant = $u & 0x07FF;
if ($u & 0x8000) $mant -= 2048;
$float = round(0.01 * $mant * (2 ** $exp), 2);
return "{$hexLabel} · {$u} unsigned / evtl. DPT9: {$float}";
}
if ($len === 4) {
$u = unpack('N', $bytes)[1];
$f = unpack('G', $bytes)[1] ?? null;
$extra = $f !== null ? ' / evtl. DPT14: ' . round($f, 3) : '';
return "{$hexLabel} · {$u} unsigned{$extra}";
}
return "{$hexLabel} · {$len} Byte(s)";
}
private function decodeCemi(string $cemi): array {
if (strlen($cemi) < 9) {
throw new Exception('cEMI Frame zu kurz');
}
$offset = 0;
$messageCode = ord($cemi[$offset]); $offset++;
$addInfoLen = ord($cemi[$offset]); $offset++;
$offset += $addInfoLen;
$ctrl1 = ord($cemi[$offset]); $offset++;
$ctrl2 = ord($cemi[$offset]); $offset++;
$src = (ord($cemi[$offset]) << 8) | ord($cemi[$offset + 1]); $offset += 2;
$dst = (ord($cemi[$offset]) << 8) | ord($cemi[$offset + 1]); $offset += 2;
$dataLen = ord($cemi[$offset]); $offset++;
$offset++;
$apciPayload = substr($cemi, $offset, $dataLen);
$isGroup = (bool)($ctrl2 & 0x80);
$hopCount = ($ctrl2 >> 4) & 0x07;
$priorityBits = ($ctrl1 >> 2) & 0x03;
$priorityMap = [0 => 'System', 1 => 'Normal', 2 => 'Dringend', 3 => 'Niedrig'];
$isRepetition = !(bool)(($ctrl1 >> 5) & 0x01);
$ackRequested = (bool)(($ctrl1 >> 1) & 0x01);
$broadcast = (bool)(($ctrl1 >> 4) & 0x01);
$apciFirstByte = strlen($apciPayload) > 0 ? ord($apciPayload[0]) : 0;
$apciMarker = ($apciFirstByte >> 6) & 0x03;
$eventType = match ($apciMarker) {
0 => 'GroupValueRead',
1 => 'GroupValueResponse',
2 => 'GroupValueWrite',
default => 'Sonstiger/erweiterter Dienst',
};
if ($dataLen === 1) {
$embedded = $apciFirstByte & 0x3F;
$hexLabel = '0x' . strtoupper(sprintf('%02X', $embedded));
$valueGuess = "{$hexLabel} · Eingebettet: {$embedded} (z.B. Bit/DPT1: " . ($embedded & 0x01) . ')';
} else {
$dataBytes = substr($apciPayload, 1);
$valueGuess = $this->guessValue($dataBytes);
}
$isSecure = ($messageCode === 0xFB);
return [
'kind' => 'telegram',
'time' => round(microtime(true) * 1000),
'messageType' => $this->messageCodeText($messageCode),
'addrType' => $isGroup ? 'Gruppe' : 'Einzel',
'source' => $this->formatIndividualAddress($src),
'destination' => $isGroup ? $this->formatGroupAddress($dst) : $this->formatIndividualAddress($dst),
'priority' => $priorityMap[$priorityBits] ?? 'unbekannt',
'hopCount' => $hopCount,
'repeated' => $isRepetition,
'ackRequested' => $ackRequested,
'broadcast' => $broadcast,
'isSecure' => $isSecure,
'eventType' => $eventType,
'dataLen' => $dataLen,
'value' => $valueGuess,
];
}
}
function knx_db(): PDO {
static $pdo = null;
if ($pdo === null) {
$path = __DIR__ . '/knx_telegrams.sqlite';
$pdo = new PDO('sqlite:' . $path);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA journal_mode=WAL;');
$pdo->exec("CREATE TABLE IF NOT EXISTS telegrams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts_ms INTEGER NOT NULL,
message_type TEXT,
addr_type TEXT,
source TEXT,
destination TEXT,
priority TEXT,
hop_count INTEGER,
repeated INTEGER,
ack_requested INTEGER,
broadcast INTEGER,
is_secure INTEGER,
event_type TEXT,
data_len INTEGER,
value TEXT,
raw TEXT
)");
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_telegrams_ts ON telegrams(ts_ms)");
}
return $pdo;
}
function knx_store_telegram(array $t): int {
$stmt = knx_db()->prepare("INSERT INTO telegrams
(ts_ms, message_type, addr_type, source, destination, priority, hop_count, repeated, ack_requested, broadcast, is_secure, event_type, data_len, value, raw)
VALUES (:ts, :mt, :at, :src, :dst, :prio, :hop, :rep, :ack, :bc, :sec, :et, :dl, :val, :raw)");
$stmt->execute([
'ts' => (int)$t['time'],
'mt' => $t['messageType'],
'at' => $t['addrType'],
'src' => $t['source'],
'dst' => $t['destination'],
'prio' => $t['priority'],
'hop' => $t['hopCount'],
'rep' => $t['repeated'] ? 1 : 0,
'ack' => $t['ackRequested'] ? 1 : 0,
'bc' => $t['broadcast'] ? 1 : 0,
'sec' => $t['isSecure'] ? 1 : 0,
'et' => $t['eventType'],
'dl' => $t['dataLen'],
'val' => $t['value'],
'raw' => $t['raw'] ?? '',
]);
return (int)knx_db()->lastInsertId();
}
function knx_fetch_history(int $limit = 300): array {
$stmt = knx_db()->prepare("SELECT * FROM telegrams ORDER BY id DESC LIMIT :lim");
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
function knx_token_safe(string $t): string {
return preg_replace('/[^a-zA-Z0-9_\-]/', '', $t);
}
function knx_cmd_file(string $token): string {
return sys_get_temp_dir() . '/knx_cmd_' . $token . '.json';
}
function knx_state_file(string $token): string {
return sys_get_temp_dir() . '/knx_state_' . $token . '.json';
}
function knx_emit(array $data): void {
echo 'data: ' . json_encode($data) . "\n\n";
if (ob_get_level() > 0) { @ob_flush(); }
@flush();
}
$action = $_GET['action'] ?? ($_POST['action'] ?? null);
if ($action === 'history') {
header('Content-Type: application/json; charset=utf-8');
try {
$limit = min(1000, max(1, (int)($_GET['limit'] ?? 300)));
echo json_encode(['ok' => true, 'rows' => knx_fetch_history($limit)]);
} catch (\Throwable $e) {
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
}
exit;
}
if ($action === 'stream') {
set_time_limit(0);
ignore_user_abort(false);
ini_set('zlib.output_compression', '0');
ini_set('output_buffering', 'off');
ini_set('implicit_flush', '1');
while (ob_get_level() > 0) { ob_end_flush(); }
header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');
header('Connection: keep-alive');
$ip = trim($_GET['ip'] ?? '');
$token = knx_token_safe($_GET['token'] ?? '');
if ($token === '' || !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
knx_emit(['kind' => 'log', 'type' => 'error', 'msg' => 'Ungültige IP Adresse oder fehlendes Token.', 'time' => round(microtime(true) * 1000)]);
knx_emit(['kind' => 'status', 'connected' => false]);
exit;
}
@unlink(knx_cmd_file($token));
@file_put_contents(knx_state_file($token), json_encode(['connected' => false, 'ip' => $ip]));
if (!extension_loaded('sockets')) {
knx_emit(['kind' => 'log', 'type' => 'error', 'msg' => 'PHP Erweiterung "sockets" ist nicht aktiv. In php.ini die Zeile "extension=sockets" aktivieren (führendes Semikolon entfernen), speichern, Apache im XAMPP Control Panel neu starten.', 'time' => round(microtime(true) * 1000)]);
knx_emit(['kind' => 'status', 'connected' => false]);
exit;
}
$knx = new KNXTunnel($ip);
try {
$connected = $knx->connect();
} catch (\Throwable $e) {
knx_emit(['kind' => 'log', 'type' => 'error', 'msg' => 'Fataler Fehler beim Verbindungsaufbau: ' . $e->getMessage() . ' (Datei: ' . basename($e->getFile()) . ', Zeile: ' . $e->getLine() . ')', 'time' => round(microtime(true) * 1000)]);
knx_emit(['kind' => 'status', 'connected' => false]);
exit;
}
foreach ($knx->getLog() as $e) { knx_emit(array_merge(['kind' => 'log'], $e)); }
$knx->clearLog();
if (!$connected) {
knx_emit(['kind' => 'status', 'connected' => false]);
@unlink(knx_state_file($token));
$knx->disconnect();
exit;
}
@file_put_contents(knx_state_file($token), json_encode(['connected' => true, 'ip' => $ip, 'channel' => $knx->getChannelId()]));
knx_emit(['kind' => 'status', 'connected' => true, 'channel' => $knx->getChannelId()]);
$lastHeartbeat = time();
$cmdPath = knx_cmd_file($token);
while (!connection_aborted()) {
try {
if (is_file($cmdPath)) {
$raw = @file_get_contents($cmdPath);
@unlink($cmdPath);
$cmd = json_decode((string)$raw, true);
if (is_array($cmd)) {
if (($cmd['cmd'] ?? '') === 'disconnect') {
$knx->disconnect();
foreach ($knx->getLog() as $e) { knx_emit(array_merge(['kind' => 'log'], $e)); }
knx_emit(['kind' => 'status', 'connected' => false, 'reason' => 'manuell getrennt']);
@unlink(knx_state_file($token));
exit;
} elseif (($cmd['cmd'] ?? '') === 'send') {
$knx->send($cmd['ga'] ?? '', $cmd['dpt'] ?? 'DPT1', $cmd['value'] ?? '');
foreach ($knx->getLog() as $e) { knx_emit(array_merge(['kind' => 'log'], $e)); }
$knx->clearLog();
foreach ($knx->getTelegramQueue() as $t) { knx_emit($t); }
$knx->clearTelegramQueue();
}
}
}
$knx->pollIncoming();
foreach ($knx->getLog() as $e) { knx_emit(array_merge(['kind' => 'log'], $e)); }
$knx->clearLog();
foreach ($knx->getTelegramQueue() as $t) { knx_emit($t); }
$knx->clearTelegramQueue();
$knx->maintainHeartbeat($lastHeartbeat);
foreach ($knx->getLog() as $e) { knx_emit(array_merge(['kind' => 'log'], $e)); }
$knx->clearLog();
} catch (\Throwable $e) {
knx_emit(['kind' => 'log', 'type' => 'error', 'msg' => 'Unerwarteter Fehler: ' . $e->getMessage(), 'time' => round(microtime(true) * 1000)]);
usleep(500000);
}
}
$knx->disconnect();
@unlink(knx_state_file($token));
exit;
}
if ($action === 'send' && $_SERVER['REQUEST_METHOD'] === 'POST') {
header('Content-Type: application/json; charset=utf-8');
$token = knx_token_safe($_POST['token'] ?? '');
if ($token === '' || !is_file(knx_state_file($token))) {
echo json_encode(['ok' => false, 'error' => 'Keine aktive Verbindung für dieses Token.']);
exit;
}
file_put_contents(knx_cmd_file($token), json_encode([
'cmd' => 'send',
'ga' => $_POST['ga'] ?? '',
'dpt' => $_POST['dpt'] ?? 'DPT1',
'value' => $_POST['value'] ?? '',
]));
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'disconnect' && $_SERVER['REQUEST_METHOD'] === 'POST') {
header('Content-Type: application/json; charset=utf-8');
$token = knx_token_safe($_POST['token'] ?? '');
if ($token !== '') {
file_put_contents(knx_cmd_file($token), json_encode(['cmd' => 'disconnect']));
}
echo json_encode(['ok' => true]);
exit;
}
header('Content-Type: text/html; charset=utf-8');
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KNX IP Gateway Tunnel</title>
<style>
:root, html[data-theme="dark"] {
--bg: #0d1117;
--panel: #11161d;
--panel-2: #161c25;
--border: #232b36;
--text: #d6e2ef;
--muted: #6b7684;
--accent: #58a6ff;
--green: #3fb950;
--red: #f85149;
--amber: #d29922;
--cyan: #56d4dd;
--code-bg: #06080c;
}
html[data-theme="light"] {
--bg: #eef1ef;
--panel: #ffffff;
--panel-2: #f4f6f5;
--border: #d7ddd9;
--text: #182320;
--muted: #5b6b64;
--accent: #2563eb;
--green: #1f7a3d;
--red: #c0392b;
--amber: #a9631a;
--cyan: #0f8b8d;
--code-bg: #f4f6f5;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding: 24px;
transition: background .2s, color .2s;
}
.app { width: 100%; max-width: 1100px; margin: 0 auto; display: flex; flex-direction: column; gap: 18px; }
.topbar { display: flex; align-items: center; justify-content: space-between; }
h1 { font-size: 1.3rem; font-weight: 600; margin: 0; display: flex; align-items: center; gap: 10px; }
h1 .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--green); box-shadow: 0 0 8px var(--green); }
.subtitle { color: var(--muted); font-size: 0.85rem; margin-top: 4px; }
.theme-toggle {
background: var(--panel); border: 1px solid var(--border); color: var(--text);
padding: 7px 12px; border-radius: 8px; cursor: pointer; font-size: 0.85rem;
display: flex; align-items: center; gap: 6px;
}
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 18px 20px; }
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; }
.field { display: flex; flex-direction: column; gap: 6px; flex: 1; min-width: 140px; }
.field label { font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
.field input, .field select {
background: var(--panel-2); border: 1px solid var(--border); color: var(--text);
padding: 9px 11px; border-radius: 8px; font-size: 0.92rem;
font-family: ui-monospace, Consolas, 'Liberation Mono', Menlo, monospace; outline: none;
}
.field input:focus, .field select:focus { border-color: var(--accent); }
.hint { font-size: 0.72rem; color: var(--muted); margin-top: 2px; }
button { border: none; padding: 10px 18px; border-radius: 8px; font-size: 0.9rem; font-weight: 600; cursor: pointer; white-space: nowrap; }
button.primary { background: linear-gradient(180deg, #2563eb, #1d4ed8); color: #fff; }
button.danger { background: linear-gradient(180deg, #e5534b, #c0392b); color: #fff; }
button.secondary { background: var(--panel-2); border: 1px solid var(--border); color: var(--text); }
button:disabled { opacity: 0.45; cursor: not-allowed; }
button:hover:not(:disabled) { filter: brightness(1.08); }
.status-pill { font-size: 0.72rem; padding: 3px 10px; border-radius: 20px; border: 1px solid var(--border); color: var(--muted); }
.status-pill.ok { color: var(--green); border-color: var(--green); }
.status-pill.fail { color: var(--red); border-color: var(--red); }
.status-pill.busy { color: var(--amber); border-color: var(--amber); }
.terminal { background: var(--code-bg); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
.term-bar { background: var(--panel-2); padding: 10px 14px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--border); }
.term-bar .tdot { width: 11px; height: 11px; border-radius: 50%; }
.term-bar .tdot.r { background: #ff5f56; } .term-bar .tdot.y { background: #ffbd2e; } .term-bar .tdot.g { background: #27c93f; }
.term-bar .title { margin-left: 8px; font-size: 0.78rem; color: var(--muted); font-family: monospace; flex: 1; }
.term-bar button { padding: 4px 10px; font-size: 0.72rem; background: var(--panel); border: 1px solid var(--border); color: var(--muted); }
.term-body { padding: 14px 16px; height: 320px; overflow-y: auto; font-family: ui-monospace, Consolas, Menlo, monospace; font-size: 0.8rem; line-height: 1.55; }
.line { display: flex; gap: 10px; white-space: pre-wrap; word-break: break-word; }
.line .ts { color: var(--muted); flex-shrink: 0; opacity: 0.7; }
.line .tag { flex-shrink: 0; font-weight: 700; }
.line .msg { color: var(--text); }
.line.info .tag { color: var(--cyan); } .line.send .tag { color: var(--accent); }
.line.recv .tag { color: var(--green); } .line.success .tag { color: var(--green); }
.line.error .tag { color: var(--red); } .line.warn .tag { color: var(--amber); }
.line.raw .msg { color: var(--muted); padding-left: 4px; opacity: 0.85; } .line.raw .tag { color: var(--muted); opacity: 0.5; }
.line.system .msg { color: var(--muted); font-style: italic; }
.tg-table-wrap { max-height: 420px; overflow-y: auto; overflow-x: auto; border: 1px solid var(--border); border-radius: 10px; }
table.tg-table { width: 100%; min-width: 1100px; border-collapse: collapse; font-family: ui-monospace, Consolas, Menlo, monospace; font-size: 0.78rem; }
table.tg-table th {
position: sticky; top: 0; background: var(--panel-2); text-align: left;
padding: 8px 10px; color: var(--muted); font-size: 0.66rem;
text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid var(--border);
white-space: nowrap;
}
table.tg-table td { padding: 5px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
table.tg-table tr:hover td { background: var(--panel-2); }
table.tg-table td.raw { color: var(--muted); }
table.tg-table td.val { }
.tg-event { font-weight: 700; padding: 1px 6px; border-radius: 4px; font-size: 0.72rem; }
.tg-event.write { color: var(--accent); } .tg-event.read { color: var(--cyan); } .tg-event.response { color: var(--green); }
.badge { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 0.68rem; font-family: ui-monospace, monospace; border: 1px solid var(--border); }
.badge.secure { color: var(--red); border-color: var(--red); }
.empty-row td { color: var(--muted); text-align: center; padding: 24px !important; white-space: normal; }
h2.section-title { font-family: ui-monospace, monospace; font-size: 0.95rem; margin: 0 0 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
</style>
</head>
<body>
<div class="app">
<div class="topbar">
<div>
<h1><span class="dot" id="headerDot"></span> KNX IP Gateway Tunnel</h1>
<div class="subtitle">Dauerhafte KNXnet/IP Tunnelverbindung mit Live Telegrammanzeige</div>
</div>
<button class="theme-toggle" id="themeToggle" onclick="toggleTheme()">? Dunkel</button>
</div>
<div class="panel">
<div class="row">
<div class="field" style="flex: 1.4;">
<label for="ip">Gateway IP Adresse</label>
<input type="text" id="ip" placeholder="192.168.178.42" value="192.168.178.42" autocomplete="off">
</div>
<div class="field" style="flex: 0.5; min-width: 80px;">
<label for="port">Port</label>
<input type="text" id="port" value="3671" readonly>
</div>
<button class="primary" id="connectBtn" onclick="connect()">▶ Verbinden</button>
<button class="danger" id="disconnectBtn" onclick="disconnect()" disabled>■ Trennen</button>
<span id="statusPill" class="status-pill">getrennt</span>
</div>
</div>
<div class="panel">
<h2 class="section-title">Telegramm senden</h2>
<div class="row">
<div class="field">
<label for="ga">Gruppenadresse</label>
<input type="text" id="ga" placeholder="1/0/0" value="1/0/0">
</div>
<div class="field">
<label for="dpt">DPT</label>
<select id="dpt" onchange="updateValueField()">
<option value="DPT1"selected>DPT1 – Schalten (1 Bit)</option>
<option value="DPT2">DPT2 – Gesteuert 1 Bit</option>
<option value="DPT3">DPT3 – Dimmen (4 Bit)</option>
<option value="DPT4">DPT4 – Zeichen (8 Bit)</option>
<option value="DPT5">DPT5 – Wert 0-255</option>
<option value="DPT6">DPT6 – Signed 8 Bit</option>
<option value="DPT7">DPT7 – Unsigned 16 Bit</option>
<option value="DPT8">DPT8 – Signed 16 Bit</option>
<option value="DPT9">DPT9 – Temperatur/Float 16 Bit</option>
<option value="DPT10">DPT10 – Uhrzeit</option>
<option value="DPT11">DPT11 – Datum</option>
<option value="DPT13">DPT13 – Signed 32 Bit</option>
<option value="DPT14">DPT14 – Float 32 Bit</option>
<option value="DPT16">DPT16 – Text (14 Zeichen)</option>
<option value="DPT17">DPT17 – Szenennummer</option>
<option value="DPT20">DPT20 – Aufzählung (HVAC etc.)</option>
<option value="DPT232">DPT232 – RGB Farbe</option>
</select>
</div>
<div class="field" id="valueFieldWrap">
<label for="value">Wert</label>
<select id="value"><option value="1">EIN</option><option value="0">AUS</option></select>
</div>
<button class="primary" id="sendBtn" onclick="sendTelegram()" disabled>Senden</button>
</div>
<div class="hint" id="valueHint"></div>
</div>
<div class="panel">
<h2 class="section-title">Empfangene Telegramme</h2>
<div class="tg-table-wrap">
<table class="tg-table">
<thead>
<tr>
<th>Datum / Zeit</th>
<th>Ereignis</th>
<th>Quelle</th>
<th>Ziel</th>
<th>Prio</th>
<th>Hop</th>
<th>Sec</th>
<th>Wert</th>
<th>Raw</th>
</tr>
</thead>
<tbody id="telegramBody">
<tr class="empty-row"><td colspan="9">Noch keine Telegramme empfangen.</td></tr>
</tbody>
</table>
</div>
</div>
<div class="terminal">
<div class="term-bar">
<span class="tdot r"></span><span class="tdot y"></span><span class="tdot g"></span>
<span class="title">terminal — Knx Tunnel (Rohdaten)</span>
<button onclick="document.getElementById('termBody').innerHTML=''">leeren</button>
</div>
<div class="term-body" id="termBody">
<div class="line system"><span class="ts">--:--:--</span><span class="msg">Bereit. IP eingeben und auf "Verbinden" klicken …</span></div>
</div>
</div>
</div>
<script>
let eventSource = null;
let token = null;
let isConnected = false;
const TAGS = {
info: 'ℹ', send: '→ TX', recv: '← RX', 'send-raw': ' HEX', 'recv-raw': ' HEX',
success: '✔', error: '✖', warn: '⚠', system: '·',
};
const VALUE_FIELDS = {
DPT1: () => `<label for="value">Wert</label><select id="value"><option value="1">EIN</option><option value="0">AUS</option></select>`,
DPT2: () => `<label for="value">Control,Bit</label><input type="text" id="value" value="1,1">`,
DPT3: () => `<label for="value">Control,Stepcode</label><input type="text" id="value" value="1,5">`,
DPT4: () => `<label for="value">Zeichen</label><input type="text" id="value" value="A" maxlength="1">`,
DPT5: () => `<label for="value">Wert (0-255)</label><input type="number" id="value" min="0" max="255" value="128">`,
DPT6: () => `<label for="value">Wert (-128..127)</label><input type="number" id="value" min="-128" max="127" value="10">`,
DPT7: () => `<label for="value">Wert (0-65535)</label><input type="number" id="value" min="0" max="65535" value="1000">`,
DPT8: () => `<label for="value">Wert (-32768..32767)</label><input type="number" id="value" value="100">`,
DPT9: () => `<label for="value">Wert (°C etc.)</label><input type="number" step="0.1" id="value" value="21.5">`,
DPT10: () => `<label for="value">Wochentag,HH:MM:SS</label><input type="text" id="value" value="1,14:30:00">`,
DPT11: () => `<label for="value">Datum (YYYY-MM-DD)</label><input type="date" id="value" value="2026-09-13">`,
DPT13: () => `<label for="value">Wert (32 Bit signed)</label><input type="number" id="value" value="100000">`,
DPT14: () => `<label for="value">Wert (Float)</label><input type="number" step="0.01" id="value" value="3.14">`,
DPT16: () => `<label for="value">Text (max. 14 Zeichen)</label><input type="text" id="value" maxlength="14" value="Hallo">`,
DPT17: () => `<label for="value">Szene (0-63)</label><input type="number" id="value" min="0" max="63" value="1">`,
DPT20: () => `<label for="value">Wert (0-255)</label><input type="number" id="value" min="0" max="255" value="1">`,
DPT232:() => `<label for="value">R,G,B (0-255)</label><input type="text" id="value" value="255,128,0">`,
};
function updateValueField() {
const dpt = document.getElementById('dpt').value;
document.getElementById('valueFieldWrap').innerHTML = (VALUE_FIELDS[dpt] || VALUE_FIELDS.DPT5)();
}
updateValueField();
function toggleTheme() {
const html = document.documentElement;
const isDark = html.getAttribute('data-theme') !== 'light';
html.setAttribute('data-theme', isDark ? 'light' : 'dark');
document.getElementById('themeToggle').textContent = isDark ? '☀️ Hell' : '? Dunkel';
try { localStorage.setItem('knx-theme', isDark ? 'light' : 'dark'); } catch (e) {}
}
(function initTheme() {
let saved = 'dark';
try { saved = localStorage.getItem('knx-theme') || 'dark'; } catch (e) {}
document.documentElement.setAttribute('data-theme', saved);
document.getElementById('themeToggle').textContent = saved === 'dark' ? '? Dunkel' : '☀️ Hell';
})();
function fmtDateTime(ms) {
const d = new Date(ms);
const date = d.toLocaleDateString('de-DE');
const time = d.toTimeString().split(' ')[0] + '.' + String(d.getMilliseconds()).padStart(3, '0');
return date + ' ' + time;
}
function appendTermLine(type, msg, time) {
const body = document.getElementById('termBody');
const line = document.createElement('div');
const cls = (type === 'send-raw' || type === 'recv-raw') ? type.split('-')[0] + ' raw' : type;
line.className = 'line ' + cls;
line.innerHTML = `<span class="ts">${time ? fmtDateTime(time).split(' ')[1] : '--:--:--'}</span><span class="tag">${TAGS[type] || '·'}</span><span class="msg"></span>`;
line.querySelector('.msg').textContent = msg;
body.appendChild(line);
body.scrollTop = body.scrollHeight;
}
function eventClass(eventType) {
if (eventType === 'GroupValueWrite') return 'write';
if (eventType === 'GroupValueRead') return 'read';
if (eventType === 'GroupValueResponse') return 'response';
return '';
}
function addTelegramRow(t, prepend = true) {
const body = document.getElementById('telegramBody');
const empty = body.querySelector('.empty-row');
if (empty) empty.remove();
const tr = document.createElement('tr');
const rawShort = (t.raw || '').length > 46 ? t.raw.slice(0, 46) + '…' : (t.raw || '');
tr.innerHTML = `
<td>${fmtDateTime(t.time)}</td>
<td><span class="tg-event ${eventClass(t.eventType)}">${t.eventType}</span></td>
<td>${t.source}</td>
<td>${t.destination}</td>
<td>${t.priority}</td>
<td>${t.hopCount}</td>
<td>${t.isSecure ? '<span class="badge secure">JA</span>' : ''}</td>
<td class="val" title="${(t.value || '').replace(/"/g, '"')}">${t.value}</td>
<td class="raw" title="${(t.raw || '').replace(/"/g, '"')}">${rawShort}</td>`;
if (prepend) body.prepend(tr); else body.appendChild(tr);
while (body.children.length > 300) body.removeChild(body.lastChild);
}
function mapHistoryRow(r) {
return {
time: r.ts_ms,
messageType: r.message_type,
addrType: r.addr_type,
source: r.source,
destination: r.destination,
priority: r.priority,
hopCount: r.hop_count,
repeated: !!r.repeated,
ackRequested: !!r.ack_requested,
broadcast: !!r.broadcast,
isSecure: !!r.is_secure,
eventType: r.event_type,
dataLen: r.data_len,
value: r.value,
raw: r.raw,
};
}
async function loadHistory() {
try {
const res = await fetch(window.location.pathname + '?action=history&limit=300');
const data = await res.json();
if (data.ok && Array.isArray(data.rows) && data.rows.length > 0) {
for (const r of data.rows) addTelegramRow(mapHistoryRow(r), false);
}
} catch (e) { }
}
loadHistory();
function setStatus(text, cls) {
const pill = document.getElementById('statusPill');
pill.textContent = text;
pill.className = 'status-pill' + (cls ? ' ' + cls : '');
}
function setConnectedUI(connected) {
isConnected = connected;
document.getElementById('connectBtn').disabled = connected;
document.getElementById('disconnectBtn').disabled = !connected;
document.getElementById('sendBtn').disabled = !connected;
document.getElementById('ip').readOnly = connected;
document.getElementById('headerDot').style.background = connected ? 'var(--green)' : 'var(--red)';
}
function connect() {
const ip = document.getElementById('ip').value.trim();
if (!ip) { appendTermLine('error', 'Bitte eine IP-Adresse eingeben.', Date.now()); return; }
token = (crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + Math.random()).replace(/[^a-zA-Z0-9-]/g, '');
appendTermLine('system', `Starte Verbindungsaufbau zu ${ip}:3671 …`, Date.now());
setStatus('verbinde …', 'busy');
document.getElementById('connectBtn').disabled = true;
const url = window.location.pathname + '?action=stream&ip=' + encodeURIComponent(ip) + '&token=' + encodeURIComponent(token);
eventSource = new EventSource(url);
eventSource.onmessage = (ev) => {
let data;
try { data = JSON.parse(ev.data); } catch (e) { return; }
if (data.kind === 'log') {
appendTermLine(data.type, data.msg, data.time);
} else if (data.kind === 'telegram') {
addTelegramRow(data, true);
} else if (data.kind === 'status') {
if (data.connected) {
setStatus('verbunden (Kanal ' + data.channel + ')', 'ok');
setConnectedUI(true);
} else {
setStatus(data.reason ? data.reason : 'getrennt', data.reason ? '' : 'fail');
setConnectedUI(false);
if (eventSource) { eventSource.close(); eventSource = null; }
}
}
};
eventSource.onerror = () => {
appendTermLine('error', 'Stream Verbindung unterbrochen.', Date.now());
setStatus('unterbrochen', 'fail');
setConnectedUI(false);
if (eventSource) { eventSource.close(); eventSource = null; }
};
}
async function disconnect() {
if (!token) return;
document.getElementById('disconnectBtn').disabled = true;
try {
await fetch(window.location.pathname, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'action=disconnect&token=' + encodeURIComponent(token),
});
} catch (e) {
appendTermLine('error', 'Trennen fehlgeschlagen: ' + e.message, Date.now());
}
}
async function sendTelegram() {
if (!isConnected || !token) return;
const ga = document.getElementById('ga').value.trim();
const dpt = document.getElementById('dpt').value;
const value = document.getElementById('value').value;
const params = new URLSearchParams();
params.set('action', 'send');
params.set('token', token);
params.set('ga', ga);
params.set('dpt', dpt);
params.set('value', value);
try {
const res = await fetch(window.location.pathname, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
const data = await res.json();
if (!data.ok) { appendTermLine('error', data.error || 'Senden fehlgeschlagen.', Date.now()); }
} catch (e) {
appendTermLine('error', 'Senden fehlgeschlagen: ' + e.message, Date.now());
}
}
</script>
</body>
</html>

