Note de ce sujet :
  • Moyenne : 0 (0 vote(s))
  • 1
  • 2
  • 3
  • 4
  • 5
Script PHP pour connexion par tunnel IP KNX
#2
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. 

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 $ipint $port 3671) {
        $this->ip $ip;
        $this->port $port;
    }

    private function log(string $msgstring $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_INETSOCK_DGRAMSOL_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_INETSOCK_DGRAMSOL_UDP);
        if (!$this->socket) {
            $this->log('Socket konnte nicht erstellt werden: ' socket_strerror(socket_last_error()), 'error');
            return false;
        }

        socket_set_option($this->socketSOL_SOCKETSO_RCVTIMEO, ['sec' => 3'usec' => 0]);

        $myPcIp $this->getLocalIpFor($this->ip);
        $this->log("Verwende lokale IP: {$myPcIp}"'info');

        if (!@socket_bind($this->socket$myPcIp0)) {
            $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*'0x080x01$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$connectRequeststrlen($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$buffer10240$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->socketSOL_SOCKETSO_RCVTIMEO, ['sec' => 0'usec' => 250000]);
        $this->log("Tunnel-Verbindung aufgebaut (Channel ID: {$this->channelId})"'success');
        return true;
    }

    public function disconnect(): void {
        if ($this->channelId !== && $this->socket) {
            $ipParts array_map('intval'explode('.'$this->localIp ?: '0.0.0.0'));
            $hpai pack('C*'0x080x01$ipParts[0], $ipParts[1], $ipParts[2], $ipParts[3]) . pack('n'$this->localPort);
            $req "\x06\x10\x02\x09\x00\x10" pack('CC'$this->channelId0x00) . $hpai;
            @socket_sendto($this->socket$reqstrlen($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*'0x080x01$ipParts[0], $ipParts[1], $ipParts[2], $ipParts[3]) . pack('n'$this->localPort);
        $req "\x06\x10\x02\x07\x00\x10" pack('CC'$this->channelId0x00) . $hpai;
        @socket_sendto($this->socket$reqstrlen($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$buffer10240$from$fromPort);
        if ($bytes === false || $bytes 8) return;
        $this->handleIncomingPacket($buffer$bytes);
    }

    private function handleIncomingPacket(string $bufferint $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($buffer10);
            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 >= 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'$seq0x00);
        @socket_sendto($this->socket$ackstrlen($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($valueint $d1int $d2): array {
        $parts array_map('intval'explode(',', (string)$value));
        return [$parts[0] ?? $d1$parts[1] ?? $d2];
    }

    private function encodeDpt9Float(float $v): string {
        $sign $v 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) ? 0));

            case 'DPT2':
                [$c$b] = $this->splitPair($value00);
                return pack('C'0x80 | (($c 1) << 1) | ($b 1));

            case 'DPT3':
                [$c$s] = $this->splitPair($value00);
                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'0x80max(0min(255, (int)$value)));

            case 'DPT6':
                return pack('C'0x80) . pack('c'max(-128min(127, (int)$value)));

            case 'DPT7':
                return pack('C'0x80) . pack('n'max(0min(65535, (int)$value)));

            case 'DPT8':
                $v max(-32768min(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(','$str2);
                    $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)$value014);
                return pack('C'0x80) . str_pad($s14"\0");

            case 'DPT17':
                return pack('C'0x80 | (max(0min(63, (int)$value)) & 0x3F));

            case 'DPT20':
                return pack('CC'0x80max(0min(255, (int)$value)));

            case 'DPT232':
                $parts array_map('intval'explode(',', (string)$value));
                $r max(0min(255$parts[0] ?? 0));
                $g max(0min(255$parts[1] ?? 0));
                $b max(0min(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 $groupAddressstring $dptType$value): bool {
        if ($this->channelId === || !$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 strlen($cemi);
        $header "\x06\x10\x04\x20" pack('n'$totalLen);
        $mySeq $this->sequenceNumber;
        $connectionHeader pack('CC'0x04$this->channelId) . pack('CC'$mySeq0x00);
        $packet $header $connectionHeader $cemi;

        socket_sendto($this->socket$packetstrlen($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($mySeq2.0);
        $this->sequenceNumber = ($this->sequenceNumber 1) % 256;
        return $ok;
    }

    private function waitForAck(int $expectedSeqfloat $timeoutSec): bool {
        $deadline microtime(true) + $timeoutSec;
        while (microtime(true) < $deadline) {
            $buffer '';
            $from '';
            $fromPort 0;
            $bytes = @socket_recvfrom($this->socket$buffer10240$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 ? ('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 * (** $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($f3) : '';
            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 = [=> 'System'=> 'Normal'=> 'Dringend'=> 'Niedrig'];
        $isRepetition = !(bool)(($ctrl1 >> 5) & 0x01);
        $ackRequested = (bool)(($ctrl1 >> 1) & 0x01);
        $broadcast = (bool)(($ctrl1 >> 4) & 0x01);

        $apciFirstByte strlen($apciPayload) > 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($apciPayload1);
            $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_ERRMODEPDO::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'] ? 0,
        'ack' => $t['ackRequested'] ? 0,
        'bc' => $t['broadcast'] ? 0,
        'sec' => $t['isSecure'] ? 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'$limitPDO::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(1000max(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($ipFILTER_VALIDATE_IPFILTER_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)$rawtrue);
                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, '&quot;')}">${t.value}</td>
        <td class="raw" title="${(t.raw || '').replace(/"/g, '&quot;')}">${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> 
Répondre


Messages dans ce sujet
Script PHP pour connexion par tunnel IP KNX - par Smart Guido 73 - 16/09/2026, 20:45:43

Atteindre :


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