From 608740a27a070bb05580fe187a82805254d7d4c8 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Tue, 7 Apr 2026 22:06:42 +0200 Subject: [PATCH 01/14] feat(printer): add NetworkPrinter directory structure, constants and exceptions --- .../PrinterCommunicationException.php | 7 ++++ .../Exception/PrinterConfigException.php | 7 ++++ .../Exception/PrinterConnectionException.php | 7 ++++ .../Exception/PrinterException.php | 7 ++++ .../Exception/PrinterProtocolException.php | 7 ++++ .../Printer/NetworkPrinter/PrinterType.php | 32 ++++++++++++++++ www/lib/Printer/NetworkPrinter/Protocol.php | 38 +++++++++++++++++++ 7 files changed, 105 insertions(+) create mode 100644 www/lib/Printer/NetworkPrinter/Exception/PrinterCommunicationException.php create mode 100644 www/lib/Printer/NetworkPrinter/Exception/PrinterConfigException.php create mode 100644 www/lib/Printer/NetworkPrinter/Exception/PrinterConnectionException.php create mode 100644 www/lib/Printer/NetworkPrinter/Exception/PrinterException.php create mode 100644 www/lib/Printer/NetworkPrinter/Exception/PrinterProtocolException.php create mode 100644 www/lib/Printer/NetworkPrinter/PrinterType.php create mode 100644 www/lib/Printer/NetworkPrinter/Protocol.php diff --git a/www/lib/Printer/NetworkPrinter/Exception/PrinterCommunicationException.php b/www/lib/Printer/NetworkPrinter/Exception/PrinterCommunicationException.php new file mode 100644 index 000000000..4d42aa50c --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Exception/PrinterCommunicationException.php @@ -0,0 +1,7 @@ + Empfohlenes Protokoll pro Typ */ + const DEFAULT_PROTOCOLS = [ + self::DOCUMENT => Protocol::IPP, + self::LABEL => Protocol::RAW, + self::RECEIPT => Protocol::ESCPOS, + ]; + + /** @var array Mapping von drucker.art (DB) auf PrinterType */ + const FROM_DB_ART = [ + 0 => self::DOCUMENT, + 2 => self::LABEL, + ]; + + /** + * @param string $type + * @return string + */ + public static function getDefaultProtocol(string $type): string + { + return self::DEFAULT_PROTOCOLS[$type] ?? Protocol::RAW; + } +} diff --git a/www/lib/Printer/NetworkPrinter/Protocol.php b/www/lib/Printer/NetworkPrinter/Protocol.php new file mode 100644 index 000000000..7597500ab --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Protocol.php @@ -0,0 +1,38 @@ + Default-Ports pro Protokoll */ + const DEFAULT_PORTS = [ + self::IPP => 631, + self::RAW => 9100, + self::ESCPOS => 9100, + self::LPR => 515, + ]; + + /** + * @param string $protocol + * @return bool + */ + public static function isValid(string $protocol): bool + { + return in_array($protocol, [self::IPP, self::RAW, self::ESCPOS, self::LPR], true); + } + + /** + * @param string $protocol + * @return int + */ + public static function getDefaultPort(string $protocol): int + { + return self::DEFAULT_PORTS[$protocol] ?? 9100; + } +} From 7e9ad2286ad2877daca66197df74dca9a5f07c36 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Tue, 7 Apr 2026 22:08:32 +0200 Subject: [PATCH 02/14] feat(printer): add DriverInterface and RawDriver for TCP/9100 printing --- .../NetworkPrinter/Driver/DriverInterface.php | 38 ++++++ .../NetworkPrinter/Driver/RawDriver.php | 117 ++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 www/lib/Printer/NetworkPrinter/Driver/DriverInterface.php create mode 100644 www/lib/Printer/NetworkPrinter/Driver/RawDriver.php diff --git a/www/lib/Printer/NetworkPrinter/Driver/DriverInterface.php b/www/lib/Printer/NetworkPrinter/Driver/DriverInterface.php new file mode 100644 index 000000000..4122a8e4a --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Driver/DriverInterface.php @@ -0,0 +1,38 @@ + true, 'color' => true, 'tray' => true] + */ + public function getCapabilities(): array; +} diff --git a/www/lib/Printer/NetworkPrinter/Driver/RawDriver.php b/www/lib/Printer/NetworkPrinter/Driver/RawDriver.php new file mode 100644 index 000000000..f16eacb90 --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Driver/RawDriver.php @@ -0,0 +1,117 @@ +host = $host; + $this->port = $port; + $this->timeout = $timeout; + } + + /** + * {@inheritdoc} + */ + public function send(string $data, array $options = []): bool + { + $address = sprintf('tcp://%s:%d', $this->host, $this->port); + + $fp = @stream_socket_client( + $address, + $errno, + $errstr, + 5 // Connect-Timeout: 5 Sekunden + ); + + if ($fp === false) { + throw new PrinterConnectionException( + sprintf('Verbindung zu %s fehlgeschlagen: %s (%d)', $address, $errstr, $errno) + ); + } + + try { + stream_set_timeout($fp, $this->timeout); + + $dataLen = strlen($data); + $written = 0; + + while ($written < $dataLen) { + $chunk = @fwrite($fp, substr($data, $written)); + if ($chunk === false || $chunk === 0) { + $meta = stream_get_meta_data($fp); + if (!empty($meta['timed_out'])) { + throw new PrinterCommunicationException( + sprintf('Timeout beim Senden an %s nach %d/%d Bytes', $address, $written, $dataLen) + ); + } + throw new PrinterCommunicationException( + sprintf('Schreibfehler an %s nach %d/%d Bytes', $address, $written, $dataLen) + ); + } + $written += $chunk; + } + + fflush($fp); + + return true; + } finally { + fclose($fp); + } + } + + /** + * {@inheritdoc} + */ + public function isAvailable(): bool + { + $fp = @stream_socket_client( + sprintf('tcp://%s:%d', $this->host, $this->port), + $errno, + $errstr, + 3 // Schneller Connect-Check: 3 Sekunden + ); + + if ($fp === false) { + return false; + } + + fclose($fp); + return true; + } + + /** + * {@inheritdoc} + */ + public function getCapabilities(): array + { + return [ + 'duplex' => false, + 'color' => false, + 'tray' => false, + 'staple' => false, + ]; + } +} From 426f0f63a2db0a8e4a417fa353a1355e639616cc Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Tue, 7 Apr 2026 22:11:21 +0200 Subject: [PATCH 03/14] feat(printer): add IPP encoder/decoder and IppDriver for document printing Implements RFC 8011/8010 binary IPP protocol: IppEncoder handles binary encoding/decoding of Print-Job and Get-Printer-Attributes requests; IppDriver wraps it as a DriverInterface with full support for duplex, color, tray, media, staple, orientation and copies. Co-Authored-By: Claude Sonnet 4.6 --- .../NetworkPrinter/Driver/IppDriver.php | 200 ++++++++ .../NetworkPrinter/Util/IppEncoder.php | 462 ++++++++++++++++++ 2 files changed, 662 insertions(+) create mode 100644 www/lib/Printer/NetworkPrinter/Driver/IppDriver.php create mode 100644 www/lib/Printer/NetworkPrinter/Util/IppEncoder.php diff --git a/www/lib/Printer/NetworkPrinter/Driver/IppDriver.php b/www/lib/Printer/NetworkPrinter/Driver/IppDriver.php new file mode 100644 index 000000000..020bb304a --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Driver/IppDriver.php @@ -0,0 +1,200 @@ +host = $host; + $this->port = $port; + $this->timeout = $timeout; + $this->username = $username; + $this->password = $password; + $this->path = $path; + } + + /** + * {@inheritdoc} + * + * Sendet ein Dokument (PDF) per IPP Print-Job an den Drucker. + * Unterstuetzte Optionen: + * - copies (int) Anzahl Kopien + * - duplex (bool) Duplexdruck (two-sided-long-edge) + * - color (bool) Farbdruck / Monochrom + * - media (string) Medienformat z.B. 'iso_a4_210x297mm' + * - tray (string) Medienfach z.B. 'tray-1' + * - staple (bool) Heften + * - orientation (string) 'portrait' oder 'landscape' + * - job-name (string) Auftragsname + * + * @throws PrinterConnectionException Drucker nicht erreichbar + * @throws PrinterProtocolException IPP-Fehler oder ungueltiger Status + */ + public function send(string $data, array $options = []): bool + { + $printerUri = sprintf('ipp://%s:%d%s', $this->host, $this->port, $this->path); + + // Build IPP header + append PDF data + $ippHeader = IppEncoder::buildPrintJobRequest($printerUri, $options); + $ippRequest = $ippHeader . $data; + + $response = IppEncoder::sendRequest( + $this->host, + $this->port, + $this->path, + $ippRequest, + $this->username, + $this->password, + $this->timeout + ); + + $parsed = IppEncoder::parseResponse($response); + + if (!$parsed['status_ok']) { + $statusHex = sprintf('0x%04X', $parsed['status_code']); + throw new PrinterProtocolException( + sprintf( + 'IPP Print-Job fehlgeschlagen: Status %s fuer %s', + $statusHex, + $printerUri + ) + ); + } + + return true; + } + + /** + * {@inheritdoc} + * + * Prueft Erreichbarkeit per TCP-Connect auf Host:Port. + */ + public function isAvailable(): bool + { + $fp = @stream_socket_client( + sprintf('tcp://%s:%d', $this->host, $this->port), + $errno, + $errstr, + 3 + ); + + if ($fp === false) { + return false; + } + + fclose($fp); + return true; + } + + /** + * {@inheritdoc} + * + * IPP-Treiber unterstuetzt alle erweiterten Druckoptionen. + */ + public function getCapabilities(): array + { + return [ + 'duplex' => true, + 'color' => true, + 'tray' => true, + 'staple' => true, + 'media' => true, + 'copies' => true, + 'orientation' => true, + ]; + } + + /** + * Ruft Druckerattribute per IPP Get-Printer-Attributes ab. + * + * @param array $requestedAttributes Gewuenschte Attribute (leer = Standardset) + * + * @return array Assoziatives Array der Druckerattribute + * + * @throws PrinterConnectionException Drucker nicht erreichbar + * @throws PrinterProtocolException IPP-Fehler + */ + public function getPrinterAttributes(array $requestedAttributes = []): array + { + if (empty($requestedAttributes)) { + $requestedAttributes = [ + 'printer-name', + 'printer-make-and-model', + 'printer-state', + 'printer-state-reasons', + 'printer-is-accepting-jobs', + ]; + } + + $printerUri = sprintf('ipp://%s:%d%s', $this->host, $this->port, $this->path); + + $ippRequest = IppEncoder::buildGetPrinterAttributesRequest($printerUri, $requestedAttributes); + + $response = IppEncoder::sendRequest( + $this->host, + $this->port, + $this->path, + $ippRequest, + $this->username, + $this->password, + $this->timeout + ); + + $parsed = IppEncoder::parseResponse($response); + + if (!$parsed['status_ok']) { + $statusHex = sprintf('0x%04X', $parsed['status_code']); + throw new PrinterProtocolException( + sprintf( + 'IPP Get-Printer-Attributes fehlgeschlagen: Status %s fuer %s', + $statusHex, + $printerUri + ) + ); + } + + return $parsed['attributes']; + } +} diff --git a/www/lib/Printer/NetworkPrinter/Util/IppEncoder.php b/www/lib/Printer/NetworkPrinter/Util/IppEncoder.php new file mode 100644 index 000000000..2a9027cea --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Util/IppEncoder.php @@ -0,0 +1,462 @@ + 1) { + $body .= self::encodeIntegerAttribute('copies', (int)$options['copies']); + } + + // duplex -> sides + if (!empty($options['duplex'])) { + $sides = 'two-sided-long-edge'; + $body .= self::encodeAttribute(self::TAG_KEYWORD, 'sides', $sides); + } else { + $body .= self::encodeAttribute(self::TAG_KEYWORD, 'sides', 'one-sided'); + } + + // color -> print-color-mode + if (isset($options['color'])) { + $colorMode = $options['color'] ? 'color' : 'monochrome'; + $body .= self::encodeAttribute(self::TAG_KEYWORD, 'print-color-mode', $colorMode); + } + + // media + if (!empty($options['media'])) { + $body .= self::encodeAttribute(self::TAG_KEYWORD, 'media', (string)$options['media']); + } + + // tray -> media-source + if (!empty($options['tray'])) { + $body .= self::encodeAttribute(self::TAG_KEYWORD, 'media-source', (string)$options['tray']); + } + + // staple -> finishings (enum) + if (!empty($options['staple'])) { + $body .= self::encodeEnumAttribute('finishings', self::FINISHING_STAPLE); + } + + // orientation -> orientation-requested (enum) + if (!empty($options['orientation'])) { + $orientation = (strtolower((string)$options['orientation']) === 'landscape') + ? self::ORIENTATION_LANDSCAPE + : self::ORIENTATION_PORTRAIT; + $body .= self::encodeEnumAttribute('orientation-requested', $orientation); + } + + // End of attributes + $body .= pack('C', self::TAG_END_ATTRS); + + return $body; + } + + /** + * Builds a binary IPP Get-Printer-Attributes request. + * + * @param string $printerUri Full IPP URI + * @param array $requestedAttributes List of attribute names to request (empty = all) + * + * @return string Binary IPP request + */ + public static function buildGetPrinterAttributesRequest(string $printerUri, array $requestedAttributes = []): string + { + static $requestId = 100; + $requestId++; + + $body = pack('CCnN', 0x01, 0x01, self::OP_GET_PRINTER_ATTRS, $requestId); + + $body .= pack('C', self::TAG_OPERATION_ATTRS); + $body .= self::encodeAttribute(self::TAG_CHARSET, 'attributes-charset', 'utf-8'); + $body .= self::encodeAttribute(self::TAG_NATURAL_LANG, 'attributes-natural-language', 'en'); + $body .= self::encodeAttribute(self::TAG_URI, 'printer-uri', $printerUri); + $body .= self::encodeAttribute(self::TAG_NAME_WITHOUT_LANG, 'requesting-user-name', 'OpenXE'); + + // requested-attributes (multi-value: first has full name, subsequent have name-length=0) + if (!empty($requestedAttributes)) { + $first = true; + foreach ($requestedAttributes as $attrName) { + if ($first) { + $body .= self::encodeAttribute(self::TAG_KEYWORD, 'requested-attributes', (string)$attrName); + $first = false; + } else { + // Additional values: value-tag + name-length=0 + value + $val = (string)$attrName; + $body .= pack('C', self::TAG_KEYWORD); + $body .= pack('n', 0); // name-length = 0 (additional value) + $body .= pack('n', strlen($val)) . $val; + } + } + } + + $body .= pack('C', self::TAG_END_ATTRS); + + return $body; + } + + /** + * Sends an IPP request via HTTP POST using curl. + * + * @param string $host Printer hostname or IP + * @param int $port TCP port (usually 631) + * @param string $path HTTP path (e.g. '/ipp/print') + * @param string $ippData Raw binary IPP data + * @param string $username Optional username for HTTP auth + * @param string $password Optional password for HTTP auth + * @param int $timeout Request timeout in seconds + * + * @return string Raw HTTP response body + * + * @throws PrinterConnectionException On curl failure + * @throws PrinterProtocolException On non-200 HTTP status + */ + public static function sendRequest( + string $host, + int $port, + string $path, + string $ippData, + string $username = '', + string $password = '', + int $timeout = 30 + ): string { + $url = sprintf('http://%s:%d%s', $host, $port, $path); + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $ippData); + curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/ipp']); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); + curl_setopt($ch, CURLOPT_BINARYTRANSFER, true); + + if ($username !== '') { + curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC | CURLAUTH_DIGEST); + curl_setopt($ch, CURLOPT_USERPWD, $username . ':' . $password); + } + + $response = curl_exec($ch); + $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + $curlErrno = curl_errno($ch); + curl_close($ch); + + if ($response === false) { + throw new PrinterConnectionException( + sprintf('IPP curl-Fehler fuer %s: %s (%d)', $url, $curlError, $curlErrno) + ); + } + + if ($httpCode !== 200) { + throw new PrinterProtocolException( + sprintf('IPP HTTP-Fehler: Status %d fuer %s', $httpCode, $url) + ); + } + + return $response; + } + + /** + * Parses a binary IPP response body. + * + * @param string $response Raw binary IPP response + * + * @return array ['status_code' => int, 'status_ok' => bool, 'attributes' => array] + */ + public static function parseResponse(string $response): array + { + if (strlen($response) < 8) { + return ['status_code' => -1, 'status_ok' => false, 'attributes' => []]; + } + + $offset = 0; + + // Version: 2 bytes + $versionMajor = ord($response[$offset]); + $versionMinor = ord($response[$offset + 1]); + $offset += 2; + + // Status-code: 2 bytes big-endian + $unpacked = unpack('n', substr($response, $offset, 2)); + $statusCode = $unpacked[1]; + $offset += 2; + + // Request-id: 4 bytes (skip) + $offset += 4; + + $attributes = []; + $currentGroup = ''; + + while ($offset < strlen($response)) { + $tag = ord($response[$offset]); + $offset++; + + // Group delimiter tags + if ($tag === self::TAG_OPERATION_ATTRS) { + $currentGroup = 'operation'; + continue; + } + if ($tag === self::TAG_JOB_ATTRS) { + $currentGroup = 'job'; + continue; + } + if ($tag === self::TAG_PRINTER_ATTRS) { + $currentGroup = 'printer'; + continue; + } + if ($tag === self::TAG_END_ATTRS) { + break; + } + + // Need at least 2 bytes for name-length + if ($offset + 2 > strlen($response)) { + break; + } + + $nameLenUnpacked = unpack('n', substr($response, $offset, 2)); + $nameLen = $nameLenUnpacked[1]; + $offset += 2; + + $attrName = ''; + if ($nameLen > 0) { + if ($offset + $nameLen > strlen($response)) { + break; + } + $attrName = substr($response, $offset, $nameLen); + $offset += $nameLen; + } + + // Value-length: 2 bytes + if ($offset + 2 > strlen($response)) { + break; + } + $valLenUnpacked = unpack('n', substr($response, $offset, 2)); + $valLen = $valLenUnpacked[1]; + $offset += 2; + + $rawValue = ''; + if ($valLen > 0) { + if ($offset + $valLen > strlen($response)) { + break; + } + $rawValue = substr($response, $offset, $valLen); + $offset += $valLen; + } + + $decodedValue = self::decodeValue($tag, $rawValue); + + if ($attrName !== '') { + // First occurrence of this attribute name + $key = $currentGroup . '.' . $attrName; + if (isset($attributes[$key])) { + // Already exists -> convert to array + if (!is_array($attributes[$key])) { + $attributes[$key] = [$attributes[$key]]; + } + $attributes[$key][] = $decodedValue; + } else { + $attributes[$key] = $decodedValue; + } + // Also store without group prefix for convenience + if (!isset($attributes[$attrName])) { + $attributes[$attrName] = $decodedValue; + } elseif (!is_array($attributes[$attrName])) { + $attributes[$attrName] = [$attributes[$attrName], $decodedValue]; + } else { + $attributes[$attrName][] = $decodedValue; + } + } else { + // Additional value for previous attribute (name-length=0) + // Append to the last known attribute + end($attributes); + $lastKey = key($attributes); + if ($lastKey !== null) { + if (!is_array($attributes[$lastKey])) { + $attributes[$lastKey] = [$attributes[$lastKey]]; + } + $attributes[$lastKey][] = $decodedValue; + } + } + } + + $statusOk = ($statusCode === self::STATUS_OK || $statusCode === self::STATUS_OK_IGNORED_SUBSTITUTED); + + return [ + 'status_code' => $statusCode, + 'status_ok' => $statusOk, + 'attributes' => $attributes, + ]; + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Encodes a single IPP attribute (string value) as binary. + * + * Format: value-tag (1B) + name-length (2B BE) + name + value-length (2B BE) + value + * + * @param int $tag Value-tag byte + * @param string $name Attribute name + * @param string $value Attribute value (string) + * + * @return string Binary encoded attribute + */ + private static function encodeAttribute(int $tag, string $name, string $value): string + { + return pack('C', $tag) + . pack('n', strlen($name)) . $name + . pack('n', strlen($value)) . $value; + } + + /** + * Encodes an IPP integer attribute (4 bytes big-endian). + * + * @param string $name Attribute name + * @param int $value Integer value + * + * @return string Binary encoded attribute + */ + private static function encodeIntegerAttribute(string $name, int $value): string + { + return pack('C', self::TAG_INTEGER) + . pack('n', strlen($name)) . $name + . pack('n', 4) + . pack('N', $value); + } + + /** + * Encodes an IPP enum attribute (4 bytes big-endian, same wire format as integer). + * + * @param string $name Attribute name + * @param int $value Enum value + * + * @return string Binary encoded attribute + */ + private static function encodeEnumAttribute(string $name, int $value): string + { + return pack('C', self::TAG_ENUM) + . pack('n', strlen($name)) . $name + . pack('n', 4) + . pack('N', $value); + } + + /** + * Decodes a raw binary value based on its value-tag. + * + * @param int $tag Value-tag byte + * @param string $rawValue Raw binary value + * + * @return mixed Decoded PHP value (int, bool, or string) + */ + private static function decodeValue(int $tag, string $rawValue) + { + switch ($tag) { + case self::TAG_INTEGER: + if (strlen($rawValue) < 4) { + return 0; + } + $u = unpack('N', $rawValue); + $val = (int)$u[1]; + // Handle signed 32-bit + if ($val >= 0x80000000) { + $val -= 0x100000000; + } + return $val; + + case self::TAG_ENUM: + if (strlen($rawValue) < 4) { + return 0; + } + $u = unpack('N', $rawValue); + return (int)$u[1]; + + case self::TAG_BOOLEAN: + return strlen($rawValue) > 0 && ord($rawValue[0]) !== 0x00; + + case self::TAG_TEXT_WITHOUT_LANG: + case self::TAG_NAME_WITHOUT_LANG: + case self::TAG_KEYWORD: + case self::TAG_URI: + case self::TAG_CHARSET: + case self::TAG_NATURAL_LANG: + case self::TAG_MIME_TYPE: + return $rawValue; + + default: + return $rawValue; + } + } +} From e793012c307bd3d7e50f9a21461ad3933f333c37 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Tue, 7 Apr 2026 22:13:19 +0200 Subject: [PATCH 04/14] feat(printer): add EscPosDriver and LprDriver --- .../NetworkPrinter/Driver/EscPosDriver.php | 93 ++++++++++++ .../NetworkPrinter/Driver/LprDriver.php | 142 ++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 www/lib/Printer/NetworkPrinter/Driver/EscPosDriver.php create mode 100644 www/lib/Printer/NetworkPrinter/Driver/LprDriver.php diff --git a/www/lib/Printer/NetworkPrinter/Driver/EscPosDriver.php b/www/lib/Printer/NetworkPrinter/Driver/EscPosDriver.php new file mode 100644 index 000000000..3fdf6fe9f --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Driver/EscPosDriver.php @@ -0,0 +1,93 @@ +host = $host; + $this->port = $port; + $this->timeout = $timeout; + } + + public function send(string $data, array $options = []): bool + { + $address = sprintf('tcp://%s:%d', $this->host, $this->port); + $fp = @stream_socket_client($address, $errno, $errstr, 5); + + if ($fp === false) { + throw new PrinterConnectionException( + sprintf('Verbindung zu Bondrucker %s fehlgeschlagen: %s (%d)', $address, $errstr, $errno) + ); + } + + try { + stream_set_timeout($fp, $this->timeout); + $dataLen = strlen($data); + $written = 0; + + while ($written < $dataLen) { + $chunk = @fwrite($fp, substr($data, $written)); + if ($chunk === false || $chunk === 0) { + $meta = stream_get_meta_data($fp); + if (!empty($meta['timed_out'])) { + throw new PrinterCommunicationException( + sprintf('Timeout beim Senden an Bondrucker %s', $address) + ); + } + throw new PrinterCommunicationException( + sprintf('Schreibfehler an Bondrucker %s nach %d/%d Bytes', $address, $written, $dataLen) + ); + } + $written += $chunk; + } + + fflush($fp); + return true; + } finally { + fclose($fp); + } + } + + public function isAvailable(): bool + { + $fp = @stream_socket_client( + sprintf('tcp://%s:%d', $this->host, $this->port), + $errno, $errstr, 3 + ); + if ($fp === false) { + return false; + } + fclose($fp); + return true; + } + + public function getCapabilities(): array + { + return [ + 'duplex' => false, + 'color' => false, + 'tray' => false, + 'staple' => false, + 'paper_width' => true, + 'auto_cut' => true, + ]; + } +} diff --git a/www/lib/Printer/NetworkPrinter/Driver/LprDriver.php b/www/lib/Printer/NetworkPrinter/Driver/LprDriver.php new file mode 100644 index 000000000..d3d08a28b --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Driver/LprDriver.php @@ -0,0 +1,142 @@ +host = $host; + $this->port = $port; + $this->timeout = $timeout; + $this->queue = $queue; + } + + public function send(string $data, array $options = []): bool + { + $address = sprintf('tcp://%s:%d', $this->host, $this->port); + $fp = @stream_socket_client($address, $errno, $errstr, 5); + + if ($fp === false) { + throw new PrinterConnectionException( + sprintf('LPR-Verbindung zu %s fehlgeschlagen: %s (%d)', $address, $errstr, $errno) + ); + } + + try { + stream_set_timeout($fp, $this->timeout); + $jobNumber = rand(100, 999); + $hostname = gethostname() ?: 'openxe'; + $username = 'openxe'; + $filename = 'document.pdf'; + + // 1. Receive-a-printer-job + $this->lprWrite($fp, "\x02" . $this->queue . "\n"); + $this->lprReadAck($fp); + + // 2. Control File + $controlFile = ''; + $controlFile .= 'H' . $hostname . "\n"; + $controlFile .= 'P' . $username . "\n"; + $controlFile .= 'l' . $filename . "\n"; + $controlFile .= 'U' . 'dfA' . $jobNumber . $hostname . "\n"; + $controlFile .= 'N' . $filename . "\n"; + $controlFileName = 'cfA' . $jobNumber . $hostname; + + $this->lprWrite($fp, sprintf("\x02%d %s\n", strlen($controlFile), $controlFileName)); + $this->lprReadAck($fp); + $this->lprWrite($fp, $controlFile . "\x00"); + $this->lprReadAck($fp); + + // 3. Data File + $dataFileName = 'dfA' . $jobNumber . $hostname; + $this->lprWrite($fp, sprintf("\x03%d %s\n", strlen($data), $dataFileName)); + $this->lprReadAck($fp); + + $dataLen = strlen($data); + $written = 0; + while ($written < $dataLen) { + $chunk = @fwrite($fp, substr($data, $written, 8192)); + if ($chunk === false || $chunk === 0) { + throw new PrinterCommunicationException( + sprintf('LPR Schreibfehler an %s nach %d/%d Bytes', $address, $written, $dataLen) + ); + } + $written += $chunk; + } + $this->lprWrite($fp, "\x00"); + $this->lprReadAck($fp); + + return true; + } finally { + fclose($fp); + } + } + + public function isAvailable(): bool + { + $fp = @stream_socket_client( + sprintf('tcp://%s:%d', $this->host, $this->port), + $errno, $errstr, 3 + ); + if ($fp === false) { + return false; + } + fclose($fp); + return true; + } + + public function getCapabilities(): array + { + return [ + 'duplex' => false, + 'color' => false, + 'tray' => false, + 'staple' => false, + ]; + } + + /** + * @param resource $fp + * @param string $data + * @throws PrinterCommunicationException + */ + private function lprWrite($fp, string $data): void + { + $written = @fwrite($fp, $data); + if ($written === false || $written < strlen($data)) { + throw new PrinterCommunicationException('LPR: Schreibfehler'); + } + } + + /** + * @param resource $fp + * @throws PrinterProtocolException + */ + private function lprReadAck($fp): void + { + $ack = fread($fp, 1); + if ($ack === false || $ack === '' || ord($ack) !== 0) { + throw new PrinterProtocolException( + sprintf('LPR-Server lehnte Befehl ab (ACK: 0x%s)', $ack !== false ? bin2hex($ack) : 'EOF') + ); + } + } +} From f17d6c32b52d0c3ef522147f5f9d5f89bdd3bc23 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Tue, 7 Apr 2026 22:13:51 +0200 Subject: [PATCH 05/14] feat(printer): add ConnectionTest for Verbindung-testen button --- .../NetworkPrinter/Util/ConnectionTest.php | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 www/lib/Printer/NetworkPrinter/Util/ConnectionTest.php diff --git a/www/lib/Printer/NetworkPrinter/Util/ConnectionTest.php b/www/lib/Printer/NetworkPrinter/Util/ConnectionTest.php new file mode 100644 index 000000000..37cb5fa63 --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Util/ConnectionTest.php @@ -0,0 +1,124 @@ +statusMonitor = new StatusMonitor(); + } + + /** + * Fuehrt einen vollstaendigen Verbindungstest durch. + * + * @param array $settings JSON-Settings aus drucker.json + * @return array Ergebnis mit: success, message, details + */ + public function test(array $settings): array + { + $host = $settings['host'] ?? ''; + $port = (int)($settings['port'] ?? 9100); + + if ($host === '') { + return [ + 'success' => false, + 'message' => 'Keine IP-Adresse konfiguriert', + 'details' => [], + ]; + } + + $result = [ + 'success' => false, + 'message' => '', + 'details' => [], + ]; + + // 1. TCP-Connect-Check + $startTime = microtime(true); + $fp = @stream_socket_client( + sprintf('tcp://%s:%d', $host, $port), + $errno, $errstr, 3 + ); + $connectTime = round((microtime(true) - $startTime) * 1000); + + if ($fp === false) { + $result['message'] = sprintf( + 'Drucker nicht erreichbar: %s:%d — %s (%d)', + $host, $port, $errstr, $errno + ); + return $result; + } + + fclose($fp); + $result['details']['connect_time_ms'] = $connectTime; + $result['details']['tcp'] = 'OK'; + + // 2. Status-Abfrage + $status = $this->statusMonitor->getStatus($settings); + + $result['success'] = true; + $result['details']['online'] = $status['online']; + $result['details']['source'] = $status['source']; + + // Nachricht zusammenbauen + $parts = []; + $parts[] = 'Verbunden'; + + if (!empty($status['name'])) { + $parts[] = $status['name']; + } + if (!empty($status['model'])) { + $parts[] = $status['model']; + } + if (!empty($status['state']) && $status['state'] !== 'unknown') { + $stateLabels = [ + 'idle' => 'Bereit', + 'printing' => 'Druckt', + 'stopped' => 'Angehalten', + 'warmup' => 'Aufwaermphase', + ]; + $parts[] = 'Status: ' . ($stateLabels[$status['state']] ?? $status['state']); + } + + $result['message'] = implode(' — ', $parts); + + // Toner-Info + if (!empty($status['supplies'])) { + $tonerParts = []; + foreach ($status['supplies'] as $supply) { + $tonerParts[] = sprintf('%s: %d%%', $supply['description'], $supply['percent']); + } + $result['details']['supplies'] = implode(', ', $tonerParts); + } + + // Papier-Info + if (!empty($status['paper'])) { + $result['details']['paper'] = sprintf( + '%d/%d (%d%%)', + $status['paper']['level'], + $status['paper']['max'], + $status['paper']['percent'] + ); + } + + // Seitenzaehler + if (isset($status['page_count'])) { + $result['details']['page_count'] = $status['page_count']; + } + + // SNMP-Hinweis + if (!$status['snmp_available']) { + $result['details']['snmp_hint'] = 'PHP-Extension snmp nicht installiert — erweiterter Status nicht verfuegbar'; + } + + return $result; + } +} From c0961feb57d82bad0fc62ddf4ba94aaa9c28c193 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Tue, 7 Apr 2026 22:14:32 +0200 Subject: [PATCH 06/14] feat(printer): add StatusMonitor with IPP and SNMP status queries Co-Authored-By: Claude Sonnet 4.6 --- .../NetworkPrinter/Status/IppStatus.php | 108 +++++++++ .../NetworkPrinter/Status/SnmpStatus.php | 211 ++++++++++++++++++ .../NetworkPrinter/Status/StatusMonitor.php | 154 +++++++++++++ 3 files changed, 473 insertions(+) create mode 100644 www/lib/Printer/NetworkPrinter/Status/IppStatus.php create mode 100644 www/lib/Printer/NetworkPrinter/Status/SnmpStatus.php create mode 100644 www/lib/Printer/NetworkPrinter/Status/StatusMonitor.php diff --git a/www/lib/Printer/NetworkPrinter/Status/IppStatus.php b/www/lib/Printer/NetworkPrinter/Status/IppStatus.php new file mode 100644 index 000000000..3ec4b55c7 --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Status/IppStatus.php @@ -0,0 +1,108 @@ + 'idle', + 4 => 'printing', + 5 => 'stopped', + ]; + $state = isset($stateMap[$printerStateRaw]) ? $stateMap[$printerStateRaw] : 'unknown'; + + $stateReasons = isset($attrs['printer-state-reasons']) + ? $attrs['printer-state-reasons'] + : null; + if ($stateReasons !== null && !is_array($stateReasons)) { + $stateReasons = [$stateReasons]; + } + + $acceptingJobs = isset($attrs['printer-is-accepting-jobs']) + ? (bool)$attrs['printer-is-accepting-jobs'] + : false; + + $queuedJobs = isset($attrs['queued-job-count']) + ? (int)$attrs['queued-job-count'] + : 0; + + $name = isset($attrs['printer-name']) + ? (string)$attrs['printer-name'] + : ''; + + $model = isset($attrs['printer-make-and-model']) + ? (string)$attrs['printer-make-and-model'] + : ''; + + return [ + 'online' => true, + 'name' => $name, + 'model' => $model, + 'state' => $state, + 'state_reasons' => $stateReasons, + 'accepting_jobs' => $acceptingJobs, + 'queued_jobs' => $queuedJobs, + 'source' => 'ipp', + ]; + } catch (\Exception $e) { + return null; + } + } +} diff --git a/www/lib/Printer/NetworkPrinter/Status/SnmpStatus.php b/www/lib/Printer/NetworkPrinter/Status/SnmpStatus.php new file mode 100644 index 000000000..eb5e701c9 --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Status/SnmpStatus.php @@ -0,0 +1,211 @@ +exceptions_enabled = true; + + $stateRaw = $this->snmpGetValue($session, self::OID_PRINTER_STATUS); + $stateInt = ($stateRaw !== null) ? (int)$this->cleanSnmpValue($stateRaw) : 2; + + $stateMap = [ + 1 => 'other', + 2 => 'unknown', + 3 => 'idle', + 4 => 'printing', + 5 => 'warmup', + ]; + $state = isset($stateMap[$stateInt]) ? $stateMap[$stateInt] : 'unknown'; + + $nameRaw = $this->snmpGetValue($session, self::OID_PRINTER_NAME); + $serialRaw = $this->snmpGetValue($session, self::OID_SERIAL_NUMBER); + + $name = ($nameRaw !== null) ? $this->cleanSnmpValue($nameRaw) : ''; + $serial = ($serialRaw !== null) ? $this->cleanSnmpValue($serialRaw) : ''; + + $supplies = $this->querySupplies($session); + + $paperLevel = null; + $paperMax = null; + $paperRaw = $this->snmpGetValue($session, self::OID_INPUT_CURRENT_LEVEL); + $paperMaxRaw = $this->snmpGetValue($session, self::OID_INPUT_MAX_CAPACITY); + if ($paperRaw !== null) { + $paperLevel = (int)$this->cleanSnmpValue($paperRaw); + } + if ($paperMaxRaw !== null) { + $paperMax = (int)$this->cleanSnmpValue($paperMaxRaw); + } + + $paper = null; + if ($paperLevel !== null && $paperMax !== null && $paperMax > 0) { + $paper = [ + 'level' => $paperLevel, + 'max' => $paperMax, + 'percent' => round(($paperLevel / $paperMax) * 100, 1), + ]; + } elseif ($paperLevel !== null) { + $paper = [ + 'level' => $paperLevel, + 'max' => $paperMax, + 'percent' => null, + ]; + } + + $pageCountRaw = $this->snmpGetValue($session, self::OID_PAGE_COUNT); + $pageCount = ($pageCountRaw !== null) + ? (int)$this->cleanSnmpValue($pageCountRaw) + : null; + + $session->close(); + + return [ + 'online' => true, + 'source' => 'snmp', + 'state' => $state, + 'name' => $name, + 'serial' => $serial, + 'supplies' => $supplies, + 'paper' => $paper, + 'page_count' => $pageCount, + ]; + } catch (\Exception $e) { + return null; + } + } + + /** + * Walks marker supply OIDs and returns an array of supply entries. + * + * @param \SNMP $session Active SNMP session + * + * @return array + */ + private function querySupplies(\SNMP $session): array + { + $supplies = []; + + try { + $descs = @$session->walk(self::OID_MARKER_SUPPLIES_DESC); + $maxes = @$session->walk(self::OID_MARKER_SUPPLIES_MAX); + $levels = @$session->walk(self::OID_MARKER_SUPPLIES_LEVEL); + + if (!is_array($descs) || empty($descs)) { + return []; + } + + $descValues = array_values($descs); + $maxValues = is_array($maxes) ? array_values($maxes) : []; + $levelValues = is_array($levels) ? array_values($levels) : []; + + foreach ($descValues as $i => $descRaw) { + $desc = $this->cleanSnmpValue((string)$descRaw); + $max = isset($maxValues[$i]) + ? (int)$this->cleanSnmpValue((string)$maxValues[$i]) + : null; + $level = isset($levelValues[$i]) + ? (int)$this->cleanSnmpValue((string)$levelValues[$i]) + : null; + + $percent = null; + if ($level !== null && $max !== null && $max > 0) { + $percent = round(($level / $max) * 100, 1); + } + + $supplies[] = [ + 'description' => $desc, + 'level' => $level, + 'max' => $max, + 'percent' => $percent, + ]; + } + } catch (\Exception $e) { + // Return whatever was collected + } + + return $supplies; + } + + /** + * Gets a single OID value with error suppression. + * + * @param \SNMP $session Active SNMP session + * @param string $oid OID to query + * + * @return string|null Raw SNMP value string or null on failure + */ + private function snmpGetValue(\SNMP $session, string $oid): ?string + { + try { + $result = @$session->get($oid); + if ($result === false || $result === null) { + return null; + } + return (string)$result; + } catch (\Exception $e) { + return null; + } + } + + /** + * Strips SNMP type prefixes and surrounding quotes from a value string. + * + * Examples: + * 'STRING: "HP LaserJet"' -> 'HP LaserJet' + * 'INTEGER: 3' -> '3' + * + * @param string $value Raw SNMP value string + * + * @return string Cleaned value + */ + private function cleanSnmpValue(string $value): string + { + // Remove type prefix (e.g. "STRING: ", "INTEGER: ", "Gauge32: ", etc.) + $value = preg_replace('/^[A-Za-z0-9]+:\s*/', '', $value); + // Remove surrounding quotes + $value = trim($value, '"'); + return trim($value); + } +} diff --git a/www/lib/Printer/NetworkPrinter/Status/StatusMonitor.php b/www/lib/Printer/NetworkPrinter/Status/StatusMonitor.php new file mode 100644 index 000000000..265e65665 --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Status/StatusMonitor.php @@ -0,0 +1,154 @@ +ippStatus = new IppStatus(); + $this->snmpStatus = new SnmpStatus(); + } + + /** + * Retrieves the current printer status by querying available sources. + * + * @param array $settings Printer settings (from drucker.json) + * + * @return array Status array with keys: online, name, model, state, + * supplies, paper, page_count, source, snmp_available + */ + public function getStatus(array $settings): array + { + $host = isset($settings['host']) ? (string)$settings['host'] : ''; + $port = isset($settings['port']) ? (int)$settings['port'] : 631; + $protocol = isset($settings['protocol']) ? (string)$settings['protocol'] : 'ipp'; + $username = ''; + $password = ''; + + if (isset($settings['auth']) && is_array($settings['auth'])) { + $username = isset($settings['auth']['username']) ? (string)$settings['auth']['username'] : ''; + $password = isset($settings['auth']['password']) ? (string)$settings['auth']['password'] : ''; + } + + $ippPath = isset($settings['path']) ? (string)$settings['path'] : '/ipp/print'; + + $result = [ + 'online' => false, + 'name' => '', + 'model' => '', + 'state' => 'unknown', + 'supplies' => [], + 'paper' => null, + 'page_count' => null, + 'source' => null, + 'snmp_available' => SnmpStatus::isExtensionAvailable(), + ]; + + if ($host === '') { + return $result; + } + + $ippResult = null; + $snmpResult = null; + + // Step 1: Try IPP if protocol is ipp + if ($protocol === 'ipp') { + $ippResult = $this->ippStatus->query($host, $port, $username, $password, $ippPath); + if ($ippResult !== null) { + $result['online'] = (bool)$ippResult['online']; + $result['name'] = (string)$ippResult['name']; + $result['model'] = (string)$ippResult['model']; + $result['state'] = (string)$ippResult['state']; + $result['source'] = 'ipp'; + } + } + + // Step 2: Always try SNMP — merges supplies/paper/page_count + $snmpCommunity = isset($settings['snmp_community']) + ? (string)$settings['snmp_community'] + : 'public'; + $snmpResult = $this->snmpStatus->query($host, $snmpCommunity); + + if ($snmpResult !== null) { + // If IPP did not succeed, use SNMP for online/state/name + if (!$result['online']) { + $result['online'] = (bool)$snmpResult['online']; + $result['state'] = (string)$snmpResult['state']; + if ($result['name'] === '' && isset($snmpResult['name'])) { + $result['name'] = (string)$snmpResult['name']; + } + if ($result['source'] === null) { + $result['source'] = 'snmp'; + } + } + + // Always merge SNMP-only fields + if (!empty($snmpResult['supplies'])) { + $result['supplies'] = $snmpResult['supplies']; + } + if ($snmpResult['paper'] !== null) { + $result['paper'] = $snmpResult['paper']; + } + if ($snmpResult['page_count'] !== null) { + $result['page_count'] = $snmpResult['page_count']; + } + } + + // Step 3: TCP fallback if still not online + if (!$result['online']) { + $tcpOnline = $this->checkTcpConnect($host, $port, 3); + if ($tcpOnline) { + $result['online'] = true; + if ($result['source'] === null) { + $result['source'] = 'tcp'; + } + } + } + + return $result; + } + + /** + * Checks whether a TCP connection can be established. + * + * @param string $host Hostname or IP + * @param int $port TCP port + * @param int $timeout Timeout in seconds + * + * @return bool + */ + private function checkTcpConnect(string $host, int $port, int $timeout): bool + { + try { + $address = sprintf('tcp://%s:%d', $host, $port); + $socket = @stream_socket_client( + $address, + $errno, + $errstr, + $timeout, + STREAM_CLIENT_CONNECT + ); + if ($socket !== false) { + fclose($socket); + return true; + } + } catch (\Exception $e) { + // Fall through + } + return false; + } +} From 551b664eea1a66095bc2ad26ccce4b98edafc666 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Tue, 7 Apr 2026 22:18:07 +0200 Subject: [PATCH 07/14] feat(printer): add NetworkPrinter main class with driver dispatch and settings UI Co-Authored-By: Claude Sonnet 4.6 --- .../Printer/NetworkPrinter/NetworkPrinter.php | 468 ++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 www/lib/Printer/NetworkPrinter/NetworkPrinter.php diff --git a/www/lib/Printer/NetworkPrinter/NetworkPrinter.php b/www/lib/Printer/NetworkPrinter/NetworkPrinter.php new file mode 100644 index 000000000..094d4e165 --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/NetworkPrinter.php @@ -0,0 +1,468 @@ + [ + 'typ' => 'text', + 'bezeichnung' => 'IP-Adresse / Hostname', + 'placeholder' => '192.168.1.100', + 'size' => '40', + ], + 'port' => [ + 'typ' => 'text', + 'bezeichnung' => 'Port', + 'placeholder' => '631', + 'size' => '10', + ], + 'printer_type' => [ + 'typ' => 'select', + 'bezeichnung' => 'Druckertyp', + 'optionen' => [ + 'document' => 'Dokumentendrucker', + 'label' => 'Etikettendrucker', + 'receipt' => 'Bondrucker', + ], + ], + 'protocol' => [ + 'typ' => 'select', + 'bezeichnung' => 'Protokoll', + 'optionen' => [ + 'ipp' => 'IPP (Internet Printing Protocol)', + 'raw' => 'RAW / JetDirect (Port 9100)', + 'escpos' => 'ESC/POS (Bondrucker)', + 'lpr' => 'LPR / LPD (RFC 1179)', + ], + ], + 'timeout' => [ + 'typ' => 'text', + 'bezeichnung' => 'Timeout in Sekunden', + 'placeholder' => '30', + 'size' => '10', + 'default' => '30', + ], + 'auth_username' => [ + 'typ' => 'text', + 'bezeichnung' => 'Benutzername (optional)', + 'placeholder' => '', + 'size' => '30', + ], + 'auth_password' => [ + 'typ' => 'text', + 'bezeichnung' => 'Passwort (optional)', + 'placeholder' => '', + 'size' => '30', + ], + 'duplex' => [ + 'typ' => 'checkbox', + 'bezeichnung' => 'Duplexdruck', + 'heading' => 'Druckoptionen (nur IPP)', + ], + 'color' => [ + 'typ' => 'checkbox', + 'bezeichnung' => 'Farbdruck', + ], + 'tray' => [ + 'typ' => 'select', + 'bezeichnung' => 'Papierfach', + 'optionen' => [ + 'auto' => 'Automatisch', + 'tray-1' => 'Fach 1', + 'tray-2' => 'Fach 2', + 'manual' => 'Manuell', + ], + ], + 'media' => [ + 'typ' => 'select', + 'bezeichnung' => 'Papierformat', + 'optionen' => [ + 'A4' => 'A4', + 'A5' => 'A5', + 'A6' => 'A6', + 'Letter' => 'Letter', + '4x6' => '4x6 Zoll', + '100x150mm' => '100x150mm', + '100x200mm' => '100x200mm', + ], + ], + 'staple' => [ + 'typ' => 'checkbox', + 'bezeichnung' => 'Heften', + ], + 'label_language' => [ + 'typ' => 'select', + 'bezeichnung' => 'Etikettensprache', + 'heading' => 'Etiketten-Optionen', + 'optionen' => [ + 'zpl' => 'ZPL (Zebra)', + 'epl2' => 'EPL2 (Eltron)', + ], + ], + 'paper_width' => [ + 'typ' => 'select', + 'bezeichnung' => 'Papierbreite', + 'heading' => 'Bondrucker-Optionen', + 'optionen' => [ + '80' => '80mm', + '58' => '58mm', + ], + ], + 'auto_cut' => [ + 'typ' => 'checkbox', + 'bezeichnung' => 'Automatischer Schnitt', + 'default' => '1', + ], + 'test_connection' => [ + 'typ' => 'submit', + 'text' => 'Verbindung testen', + ], + ]; + } + + /** + * Sendet ein Dokument an den Drucker. + * + * @param string $dokument Dateipfad oder rohe Druckdaten + * @param int $anzahl Anzahl Kopien + * + * @return bool true bei Erfolg, false bei Fehler + */ + public function printDocument($dokument, $anzahl): bool + { + try { + $settings = $this->getResolvedSettings(); + $this->validateSettings($settings); + + $data = $this->loadDocumentData($dokument); + $driver = $this->createDriver($settings); + $options = $this->buildPrintOptions($settings, (int)$anzahl); + + $driver->send($data, $options); + + $this->logInfo( + sprintf( + 'Druckauftrag erfolgreich: Drucker-ID=%d, Protokoll=%s, Host=%s, Kopien=%d', + $this->id, + $settings['protocol'], + $settings['host'], + (int)$anzahl + ) + ); + + return true; + + } catch (PrinterException $e) { + $this->logError( + sprintf( + 'Druckfehler (PrinterException): Drucker-ID=%d — %s', + $this->id, + $e->getMessage() + ) + ); + return false; + + } catch (\Exception $e) { + $this->logError( + sprintf( + 'Druckfehler (Exception): Drucker-ID=%d — %s', + $this->id, + $e->getMessage() + ) + ); + return false; + } + } + + /** + * Fragt den aktuellen Druckerstatus ab. + * + * @return array|null Status-Array oder null bei fehlender Konfiguration + */ + public function getStatus(): ?array + { + $settings = $this->getResolvedSettings(); + if (empty($settings['host'])) { + return null; + } + + $monitor = new StatusMonitor(); + return $monitor->getStatus($settings); + } + + /** + * Fuehrt einen vollstaendigen Verbindungstest durch. + * + * @return array Ergebnis mit success, message, details + */ + public function testConnection(): array + { + $settings = $this->getResolvedSettings(); + + $test = new ConnectionTest(); + return $test->test($settings); + } + + /** + * Rendert die Einstellungsseite. Behandelt den "Verbindung testen"-Button + * vor dem Aufruf der Parent-Implementierung. + * + * @param string $target Template-Target oder 'return' + * @param array|null $struktur Optionale Struktur-Ueberschreibung + * + * @return string|null HTML-Output + */ + public function Settings($target = 'return', $struktur = null) + { + $testHtml = ''; + + if (isset($this->app->Secure) && $this->app->Secure->GetPOST('test_connection')) { + $result = $this->testConnection(); + + $color = $result['success'] ? '#d4edda' : '#f8d7da'; + $border = $result['success'] ? '#c3e6cb' : '#f5c6cb'; + $icon = $result['success'] ? '✓' : '✗'; + + $testHtml = '
'; + $testHtml .= '' . $icon . ' ' . htmlspecialchars($result['message']) . ''; + + if (!empty($result['details'])) { + $testHtml .= '
    '; + foreach ($result['details'] as $key => $val) { + $testHtml .= '
  • ' . htmlspecialchars($key) . ': '; + $testHtml .= htmlspecialchars((string)$val) . '
  • '; + } + $testHtml .= '
'; + } + + $testHtml .= '
'; + } + + $parentHtml = parent::Settings($target, $struktur); + + if ($testHtml !== '') { + if ($target !== 'return' && isset($this->app->Tpl)) { + $this->app->Tpl->Add($target, $testHtml); + } else { + return $testHtml . (string)$parentHtml; + } + } + + return $parentHtml; + } + + /** + * Liest $this->settings und ergaenzt fehlende Felder mit Standardwerten. + * + * @return array Vollstaendige Settings + */ + private function getResolvedSettings(): array + { + $s = is_array($this->settings) ? $this->settings : []; + + if (!isset($s['host'])) { + $s['host'] = ''; + } + if (!isset($s['protocol']) || $s['protocol'] === '') { + $s['protocol'] = Protocol::IPP; + } + if (!isset($s['port']) || (int)$s['port'] === 0) { + $s['port'] = Protocol::getDefaultPort($s['protocol']); + } + if (!isset($s['timeout']) || (int)$s['timeout'] === 0) { + $s['timeout'] = 30; + } + if (!isset($s['auth_username'])) { + $s['auth_username'] = ''; + } + if (!isset($s['auth_password'])) { + $s['auth_password'] = ''; + } + + return $s; + } + + /** + * Prueft die Mindestanforderungen an die Konfiguration. + * + * @param array $settings + * + * @throws PrinterConfigException + */ + private function validateSettings(array $settings): void + { + if (empty($settings['host'])) { + throw new PrinterConfigException( + 'Netzwerkdrucker: Keine IP-Adresse/Hostname konfiguriert (Drucker-ID ' . $this->id . ')' + ); + } + + if (!Protocol::isValid($settings['protocol'])) { + throw new PrinterConfigException( + 'Netzwerkdrucker: Ungueltiges Protokoll "' . $settings['protocol'] . '" (Drucker-ID ' . $this->id . ')' + ); + } + } + + /** + * Laedt den Inhalt des Druckdokuments. + * Ist $dokument ein vorhandener Dateipfad, wird der Inhalt gelesen. + * Andernfalls werden die Daten unveraendert durchgereicht. + * + * @param string $dokument + * + * @return string + */ + private function loadDocumentData(string $dokument): string + { + if (is_file($dokument)) { + $content = file_get_contents($dokument); + if ($content === false) { + throw new PrinterConfigException( + 'Netzwerkdrucker: Datei konnte nicht gelesen werden: ' . $dokument + ); + } + return $content; + } + + return $dokument; + } + + /** + * Erzeugt den passenden Treiber anhand des konfigurierten Protokolls. + * + * @param array $settings + * + * @return DriverInterface + * + * @throws PrinterConfigException + */ + private function createDriver(array $settings): DriverInterface + { + $host = (string)$settings['host']; + $port = (int)$settings['port']; + $timeout = (int)$settings['timeout']; + $username = (string)$settings['auth_username']; + $password = (string)$settings['auth_password']; + + switch ($settings['protocol']) { + case Protocol::IPP: + return new IppDriver($host, $port, $timeout, $username, $password); + + case Protocol::RAW: + return new RawDriver($host, $port, $timeout); + + case Protocol::ESCPOS: + return new EscPosDriver($host, $port, $timeout); + + case Protocol::LPR: + return new LprDriver($host, $port, $timeout); + + default: + throw new PrinterConfigException( + 'Netzwerkdrucker: Unbekanntes Protokoll "' . $settings['protocol'] . '"' + ); + } + } + + /** + * Sammelt Druckoptionen aus den Settings und der Auftragsanzahl. + * + * @param array $settings + * @param int $copies + * + * @return array + */ + private function buildPrintOptions(array $settings, int $copies): array + { + $options = []; + + $options['copies'] = max(1, $copies); + + if (!empty($settings['duplex'])) { + $options['duplex'] = true; + } + if (!empty($settings['color'])) { + $options['color'] = true; + } + if (!empty($settings['media'])) { + $options['media'] = (string)$settings['media']; + } + if (!empty($settings['tray']) && $settings['tray'] !== 'auto') { + $options['tray'] = (string)$settings['tray']; + } + if (!empty($settings['staple'])) { + $options['staple'] = true; + } + if (!empty($settings['paper_width'])) { + $options['paper_width'] = (string)$settings['paper_width']; + } + if (isset($settings['auto_cut'])) { + $options['auto_cut'] = (bool)$settings['auto_cut']; + } + + return $options; + } + + /** + * Schreibt eine Info-Meldung ins Drucker-Log. + * + * @param string $message + */ + private function logInfo(string $message): void + { + if (isset($this->app->erp) && method_exists($this->app->erp, 'LogFile')) { + $this->app->erp->LogFile('printer_network', $message); + } + } + + /** + * Schreibt eine Fehlermeldung ins Fehler-Log. + * + * @param string $message + */ + private function logError(string $message): void + { + if (isset($this->app->erp) && method_exists($this->app->erp, 'LogFile')) { + $this->app->erp->LogFile('printer_error', $message); + } + } +} From ea0fbc45fbc19c7205cb95866eacc35047c08074 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Tue, 7 Apr 2026 22:24:26 +0200 Subject: [PATCH 08/14] fix(printer): fix CURLOPT_BINARYTRANSFER, auth key mismatch, LPR queue, IPP media keywords Co-Authored-By: Claude Sonnet 4.6 --- .../Printer/NetworkPrinter/NetworkPrinter.php | 26 +++++++++++++------ .../NetworkPrinter/Status/StatusMonitor.php | 6 ++--- .../NetworkPrinter/Util/IppEncoder.php | 1 - 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/www/lib/Printer/NetworkPrinter/NetworkPrinter.php b/www/lib/Printer/NetworkPrinter/NetworkPrinter.php index 094d4e165..9d9b2b584 100644 --- a/www/lib/Printer/NetworkPrinter/NetworkPrinter.php +++ b/www/lib/Printer/NetworkPrinter/NetworkPrinter.php @@ -72,6 +72,14 @@ public function SettingsStructure(): array 'lpr' => 'LPR / LPD (RFC 1179)', ], ], + 'lpr_queue' => [ + 'typ' => 'text', + 'bezeichnung' => 'LPR Queue-Name (nur LPR)', + 'placeholder' => 'lp', + 'size' => 20, + 'default' => 'lp', + 'info' => 'Standard: lp', + ], 'timeout' => [ 'typ' => 'text', 'bezeichnung' => 'Timeout in Sekunden', @@ -114,14 +122,15 @@ public function SettingsStructure(): array 'typ' => 'select', 'bezeichnung' => 'Papierformat', 'optionen' => [ - 'A4' => 'A4', - 'A5' => 'A5', - 'A6' => 'A6', - 'Letter' => 'Letter', - '4x6' => '4x6 Zoll', - '100x150mm' => '100x150mm', - '100x200mm' => '100x200mm', + 'iso_a4_210x297mm' => 'A4', + 'iso_a5_148x210mm' => 'A5', + 'iso_a6_105x148mm' => 'A6', + 'na_letter_8.5x11in' => 'Letter', + 'na_4x6_4x6in' => '4x6 Zoll (Label)', + 'om_100x150mm_100x150mm' => '100x150mm (Label)', + 'om_100x200mm_100x200mm' => '100x200mm (Label)', ], + 'default' => 'iso_a4_210x297mm', ], 'staple' => [ 'typ' => 'checkbox', @@ -394,7 +403,8 @@ private function createDriver(array $settings): DriverInterface return new EscPosDriver($host, $port, $timeout); case Protocol::LPR: - return new LprDriver($host, $port, $timeout); + $queue = $settings['lpr_queue'] ?? 'lp'; + return new LprDriver($host, $port, $timeout, $queue); default: throw new PrinterConfigException( diff --git a/www/lib/Printer/NetworkPrinter/Status/StatusMonitor.php b/www/lib/Printer/NetworkPrinter/Status/StatusMonitor.php index 265e65665..f3c4f8069 100644 --- a/www/lib/Printer/NetworkPrinter/Status/StatusMonitor.php +++ b/www/lib/Printer/NetworkPrinter/Status/StatusMonitor.php @@ -39,10 +39,8 @@ public function getStatus(array $settings): array $username = ''; $password = ''; - if (isset($settings['auth']) && is_array($settings['auth'])) { - $username = isset($settings['auth']['username']) ? (string)$settings['auth']['username'] : ''; - $password = isset($settings['auth']['password']) ? (string)$settings['auth']['password'] : ''; - } + $username = $settings['auth_username'] ?? ($settings['auth']['username'] ?? ''); + $password = $settings['auth_password'] ?? ($settings['auth']['password'] ?? ''); $ippPath = isset($settings['path']) ? (string)$settings['path'] : '/ipp/print'; diff --git a/www/lib/Printer/NetworkPrinter/Util/IppEncoder.php b/www/lib/Printer/NetworkPrinter/Util/IppEncoder.php index 2a9027cea..2ba02905b 100644 --- a/www/lib/Printer/NetworkPrinter/Util/IppEncoder.php +++ b/www/lib/Printer/NetworkPrinter/Util/IppEncoder.php @@ -199,7 +199,6 @@ public static function sendRequest( curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); - curl_setopt($ch, CURLOPT_BINARYTRANSFER, true); if ($username !== '') { curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC | CURLAUTH_DIGEST); From 6cd57dc7212c9642b2fe560823e631ae363ad37b Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Tue, 7 Apr 2026 22:37:03 +0200 Subject: [PATCH 09/14] style(printer): add missing PHPDoc, fix language consistency DE, minor null checks - EscPosDriver.php: Added PHPDoc for constructor and all public methods - LprDriver.php: Added PHPDoc for constructor and all public methods, @throws annotations - IppStatus.php: Translate class doc and query() to German - SnmpStatus.php: Translate all docs to German (class, methods, parameters) - StatusMonitor.php: Translate class doc and all method docs to German, fix constructor PHPDoc, remove redundant variable initialization - IppEncoder.php: Translate class doc and all public method docs to German - IppDriver.php: Fix English comment on line 79 to German - ConnectionTest.php: Add null check for supply['percent'] in supplies loop Co-Authored-By: Claude Opus 4.6 (1M context) --- .../NetworkPrinter/Driver/EscPosDriver.php | 14 ++++++ .../NetworkPrinter/Driver/IppDriver.php | 2 +- .../NetworkPrinter/Driver/LprDriver.php | 19 ++++++++ .../NetworkPrinter/Status/IppStatus.php | 16 +++---- .../NetworkPrinter/Status/SnmpStatus.php | 36 +++++++------- .../NetworkPrinter/Status/StatusMonitor.php | 24 +++++----- .../NetworkPrinter/Util/ConnectionTest.php | 3 +- .../NetworkPrinter/Util/IppEncoder.php | 48 +++++++++---------- 8 files changed, 99 insertions(+), 63 deletions(-) diff --git a/www/lib/Printer/NetworkPrinter/Driver/EscPosDriver.php b/www/lib/Printer/NetworkPrinter/Driver/EscPosDriver.php index 3fdf6fe9f..10e39aff0 100644 --- a/www/lib/Printer/NetworkPrinter/Driver/EscPosDriver.php +++ b/www/lib/Printer/NetworkPrinter/Driver/EscPosDriver.php @@ -20,6 +20,11 @@ class EscPosDriver implements DriverInterface /** @var int */ private $timeout; + /** + * @param string $host IP-Adresse oder Hostname + * @param int $port TCP-Port (Default: 9100) + * @param int $timeout Timeout in Sekunden (Default: 30) + */ public function __construct(string $host, int $port = 9100, int $timeout = 30) { $this->host = $host; @@ -27,6 +32,9 @@ public function __construct(string $host, int $port = 9100, int $timeout = 30) $this->timeout = $timeout; } + /** + * {@inheritdoc} + */ public function send(string $data, array $options = []): bool { $address = sprintf('tcp://%s:%d', $this->host, $this->port); @@ -66,6 +74,9 @@ public function send(string $data, array $options = []): bool } } + /** + * {@inheritdoc} + */ public function isAvailable(): bool { $fp = @stream_socket_client( @@ -79,6 +90,9 @@ public function isAvailable(): bool return true; } + /** + * {@inheritdoc} + */ public function getCapabilities(): array { return [ diff --git a/www/lib/Printer/NetworkPrinter/Driver/IppDriver.php b/www/lib/Printer/NetworkPrinter/Driver/IppDriver.php index 020bb304a..470015115 100644 --- a/www/lib/Printer/NetworkPrinter/Driver/IppDriver.php +++ b/www/lib/Printer/NetworkPrinter/Driver/IppDriver.php @@ -76,7 +76,7 @@ public function send(string $data, array $options = []): bool { $printerUri = sprintf('ipp://%s:%d%s', $this->host, $this->port, $this->path); - // Build IPP header + append PDF data + // IPP-Header bauen + PDF-Daten anhaengen $ippHeader = IppEncoder::buildPrintJobRequest($printerUri, $options); $ippRequest = $ippHeader . $data; diff --git a/www/lib/Printer/NetworkPrinter/Driver/LprDriver.php b/www/lib/Printer/NetworkPrinter/Driver/LprDriver.php index d3d08a28b..78fc2c84a 100644 --- a/www/lib/Printer/NetworkPrinter/Driver/LprDriver.php +++ b/www/lib/Printer/NetworkPrinter/Driver/LprDriver.php @@ -21,6 +21,12 @@ class LprDriver implements DriverInterface /** @var string LPR-Queuename */ private $queue; + /** + * @param string $host IP-Adresse oder Hostname + * @param int $port TCP-Port (Default: 515) + * @param int $timeout Timeout in Sekunden (Default: 30) + * @param string $queue LPR-Queuename (Default: 'lp') + */ public function __construct(string $host, int $port = 515, int $timeout = 30, string $queue = 'lp') { $this->host = $host; @@ -29,6 +35,13 @@ public function __construct(string $host, int $port = 515, int $timeout = 30, st $this->queue = $queue; } + /** + * {@inheritdoc} + * + * @throws PrinterConnectionException + * @throws PrinterCommunicationException + * @throws PrinterProtocolException + */ public function send(string $data, array $options = []): bool { $address = sprintf('tcp://%s:%d', $this->host, $this->port); @@ -90,6 +103,9 @@ public function send(string $data, array $options = []): bool } } + /** + * {@inheritdoc} + */ public function isAvailable(): bool { $fp = @stream_socket_client( @@ -103,6 +119,9 @@ public function isAvailable(): bool return true; } + /** + * {@inheritdoc} + */ public function getCapabilities(): array { return [ diff --git a/www/lib/Printer/NetworkPrinter/Status/IppStatus.php b/www/lib/Printer/NetworkPrinter/Status/IppStatus.php index 3ec4b55c7..bd3bd8f47 100644 --- a/www/lib/Printer/NetworkPrinter/Status/IppStatus.php +++ b/www/lib/Printer/NetworkPrinter/Status/IppStatus.php @@ -3,20 +3,20 @@ require_once __DIR__ . '/../Util/IppEncoder.php'; /** - * Queries printer status via IPP Get-Printer-Attributes (RFC 8011). + * Fragt Druckerstatus per IPP Get-Printer-Attributes ab (RFC 8011). */ class IppStatus { /** - * Queries the printer status via IPP. + * Fragt den Druckerstatus per IPP ab. * - * @param string $host Printer hostname or IP - * @param int $port IPP port (default 631) - * @param string $username Optional HTTP auth username - * @param string $password Optional HTTP auth password - * @param string $path IPP path (default /ipp/print) + * @param string $host Druckerhostname oder IP-Adresse + * @param int $port IPP-Port (Default: 631) + * @param string $username Optionaler HTTP-Auth-Benutzername + * @param string $password Optionales HTTP-Auth-Passwort + * @param string $path IPP-Pfad (Default: /ipp/print) * - * @return array|null Status array or null on failure + * @return array|null Status-Array oder null bei Fehler */ public function query( string $host, diff --git a/www/lib/Printer/NetworkPrinter/Status/SnmpStatus.php b/www/lib/Printer/NetworkPrinter/Status/SnmpStatus.php index eb5e701c9..af68a5d77 100644 --- a/www/lib/Printer/NetworkPrinter/Status/SnmpStatus.php +++ b/www/lib/Printer/NetworkPrinter/Status/SnmpStatus.php @@ -1,8 +1,8 @@ 'HP LaserJet' * 'INTEGER: 3' -> '3' * - * @param string $value Raw SNMP value string + * @param string $value Rohwert-String * - * @return string Cleaned value + * @return string Bereinigter Wert */ private function cleanSnmpValue(string $value): string { diff --git a/www/lib/Printer/NetworkPrinter/Status/StatusMonitor.php b/www/lib/Printer/NetworkPrinter/Status/StatusMonitor.php index f3c4f8069..7cca5fac2 100644 --- a/www/lib/Printer/NetworkPrinter/Status/StatusMonitor.php +++ b/www/lib/Printer/NetworkPrinter/Status/StatusMonitor.php @@ -4,10 +4,10 @@ require_once __DIR__ . '/SnmpStatus.php'; /** - * Orchestrates printer status queries from multiple sources. + * Orchestriert Status-Abfragen aus verschiedenen Quellen. * - * Query order: IPP first (if protocol=ipp), then SNMP (always attempted), - * then TCP socket fallback if still offline. + * Abfragereihenfolge: IPP zuerst (wenn protocol=ipp), dann SNMP (immer versucht), + * dann TCP-Socket-Fallback wenn noch offline. */ class StatusMonitor { @@ -17,18 +17,22 @@ class StatusMonitor /** @var SnmpStatus */ private $snmpStatus; - public function __construct() + /** + * @param IppStatus $ippStatus Instanz des IPP-Status-Abfragers (optional) + * @param SnmpStatus $snmpStatus Instanz des SNMP-Status-Abfragers (optional) + */ + public function __construct(IppStatus $ippStatus = null, SnmpStatus $snmpStatus = null) { - $this->ippStatus = new IppStatus(); - $this->snmpStatus = new SnmpStatus(); + $this->ippStatus = $ippStatus ?? new IppStatus(); + $this->snmpStatus = $snmpStatus ?? new SnmpStatus(); } /** - * Retrieves the current printer status by querying available sources. + * Fragt den vollstaendigen Druckerstatus ab. * - * @param array $settings Printer settings (from drucker.json) + * @param array $settings Drucker-Einstellungen (aus drucker.json) * - * @return array Status array with keys: online, name, model, state, + * @return array Status-Array mit Keys: online, name, model, state, * supplies, paper, page_count, source, snmp_available */ public function getStatus(array $settings): array @@ -36,8 +40,6 @@ public function getStatus(array $settings): array $host = isset($settings['host']) ? (string)$settings['host'] : ''; $port = isset($settings['port']) ? (int)$settings['port'] : 631; $protocol = isset($settings['protocol']) ? (string)$settings['protocol'] : 'ipp'; - $username = ''; - $password = ''; $username = $settings['auth_username'] ?? ($settings['auth']['username'] ?? ''); $password = $settings['auth_password'] ?? ($settings['auth']['password'] ?? ''); diff --git a/www/lib/Printer/NetworkPrinter/Util/ConnectionTest.php b/www/lib/Printer/NetworkPrinter/Util/ConnectionTest.php index 37cb5fa63..e2671c370 100644 --- a/www/lib/Printer/NetworkPrinter/Util/ConnectionTest.php +++ b/www/lib/Printer/NetworkPrinter/Util/ConnectionTest.php @@ -94,7 +94,8 @@ public function test(array $settings): array if (!empty($status['supplies'])) { $tonerParts = []; foreach ($status['supplies'] as $supply) { - $tonerParts[] = sprintf('%s: %d%%', $supply['description'], $supply['percent']); + $percent = isset($supply['percent']) ? $supply['percent'] : 0; + $tonerParts[] = sprintf('%s: %d%%', $supply['description'], $percent); } $result['details']['supplies'] = implode(', ', $tonerParts); } diff --git a/www/lib/Printer/NetworkPrinter/Util/IppEncoder.php b/www/lib/Printer/NetworkPrinter/Util/IppEncoder.php index 2ba02905b..5ad5f6add 100644 --- a/www/lib/Printer/NetworkPrinter/Util/IppEncoder.php +++ b/www/lib/Printer/NetworkPrinter/Util/IppEncoder.php @@ -4,10 +4,10 @@ require_once __DIR__ . '/../Exception/PrinterConnectionException.php'; /** - * IPP (Internet Printing Protocol) binary encoder/decoder. + * IPP Binaerformat Encoder/Decoder nach RFC 8010/8011. * - * Implements RFC 8011 / RFC 8010 binary format for building and parsing - * IPP requests and responses sent over HTTP POST to port 631. + * Implementiert das Binaerformat zum Zusammenbau und Parsen von + * IPP-Requests und -Responses ueber HTTP POST an Port 631. */ class IppEncoder { @@ -45,12 +45,12 @@ class IppEncoder const ORIENTATION_LANDSCAPE = 4; /** - * Builds a binary IPP Print-Job request header (without document data). + * Baut einen Print-Job IPP-Request. * - * @param string $printerUri Full IPP URI e.g. ipp://host:631/ipp/print - * @param array $options Print options (copies, duplex, color, media, tray, staple, orientation, job-name) + * @param string $printerUri Vollstaendiger IPP-URI z.B. ipp://host:631/ipp/print + * @param array $options Druck-Optionen (copies, duplex, color, media, tray, staple, orientation, job-name) * - * @return string Binary IPP request header + * @return string Binaerer IPP-Request-Header */ public static function buildPrintJobRequest(string $printerUri, array $options = []): string { @@ -123,12 +123,12 @@ public static function buildPrintJobRequest(string $printerUri, array $options = } /** - * Builds a binary IPP Get-Printer-Attributes request. + * Baut einen Get-Printer-Attributes IPP-Request. * - * @param string $printerUri Full IPP URI - * @param array $requestedAttributes List of attribute names to request (empty = all) + * @param string $printerUri Vollstaendiger IPP-URI + * @param array $requestedAttributes Liste der angeforderten Attributnamen (leer = alle) * - * @return string Binary IPP request + * @return string Binaerer IPP-Request */ public static function buildGetPrinterAttributesRequest(string $printerUri, array $requestedAttributes = []): string { @@ -166,20 +166,20 @@ public static function buildGetPrinterAttributesRequest(string $printerUri, arra } /** - * Sends an IPP request via HTTP POST using curl. + * Sendet einen IPP-Request per HTTP POST. * - * @param string $host Printer hostname or IP - * @param int $port TCP port (usually 631) - * @param string $path HTTP path (e.g. '/ipp/print') - * @param string $ippData Raw binary IPP data - * @param string $username Optional username for HTTP auth - * @param string $password Optional password for HTTP auth - * @param int $timeout Request timeout in seconds + * @param string $host Druckerhostname oder IP-Adresse + * @param int $port TCP-Port (normalerweise 631) + * @param string $path HTTP-Pfad (z.B. '/ipp/print') + * @param string $ippData Roher binaerer IPP-Datenstrom + * @param string $username Optionaler Benutzername fuer HTTP-Auth + * @param string $password Optionales Passwort fuer HTTP-Auth + * @param int $timeout Request-Timeout in Sekunden * - * @return string Raw HTTP response body + * @return string Roher HTTP-Response-Body * - * @throws PrinterConnectionException On curl failure - * @throws PrinterProtocolException On non-200 HTTP status + * @throws PrinterConnectionException Bei Curl-Fehler + * @throws PrinterProtocolException Bei nicht-200 HTTP-Status */ public static function sendRequest( string $host, @@ -227,9 +227,9 @@ public static function sendRequest( } /** - * Parses a binary IPP response body. + * Parst eine IPP-Response und gibt Status + Attribute zurueck. * - * @param string $response Raw binary IPP response + * @param string $response Rohe binaere IPP-Response * * @return array ['status_code' => int, 'status_ok' => bool, 'attributes' => array] */ From e4d2ff502a9562494ced078ee91e7ed87f14bd20 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Tue, 7 Apr 2026 22:40:18 +0200 Subject: [PATCH 10/14] security(printer): add host/port validation, IPP length guard, LPR sanitization, file size limit - NetworkPrinter: port range (1-65535), blocked hosts (SSRF), loopback block - NetworkPrinter: timeout clamped to 1-300s, file size limit 100MB - IppEncoder: encodeAttribute truncates name/value at 65535 bytes (pack overflow) - LprDriver: queue name sanitized (removes non-alphanumeric), rand -> random_int Co-Authored-By: Claude Sonnet 4.6 --- .../NetworkPrinter/Driver/LprDriver.php | 10 ++++- .../Printer/NetworkPrinter/NetworkPrinter.php | 38 ++++++++++++++++--- .../NetworkPrinter/Util/IppEncoder.php | 17 +++++++-- 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/www/lib/Printer/NetworkPrinter/Driver/LprDriver.php b/www/lib/Printer/NetworkPrinter/Driver/LprDriver.php index 78fc2c84a..454dbafce 100644 --- a/www/lib/Printer/NetworkPrinter/Driver/LprDriver.php +++ b/www/lib/Printer/NetworkPrinter/Driver/LprDriver.php @@ -55,13 +55,19 @@ public function send(string $data, array $options = []): bool try { stream_set_timeout($fp, $this->timeout); - $jobNumber = rand(100, 999); + $jobNumber = random_int(100, 999); $hostname = gethostname() ?: 'openxe'; $username = 'openxe'; $filename = 'document.pdf'; + // Queue-Name sanitizen (verhindert LPR-Command-Injection via Newline) + $queue = preg_replace('/[^a-zA-Z0-9_\-]/', '', $this->queue); + if ($queue === '') { + $queue = 'lp'; + } + // 1. Receive-a-printer-job - $this->lprWrite($fp, "\x02" . $this->queue . "\n"); + $this->lprWrite($fp, "\x02" . $queue . "\n"); $this->lprReadAck($fp); // 2. Control File diff --git a/www/lib/Printer/NetworkPrinter/NetworkPrinter.php b/www/lib/Printer/NetworkPrinter/NetworkPrinter.php index 9d9b2b584..508630654 100644 --- a/www/lib/Printer/NetworkPrinter/NetworkPrinter.php +++ b/www/lib/Printer/NetworkPrinter/NetworkPrinter.php @@ -316,7 +316,8 @@ private function getResolvedSettings(): array if (!isset($s['port']) || (int)$s['port'] === 0) { $s['port'] = Protocol::getDefaultPort($s['protocol']); } - if (!isset($s['timeout']) || (int)$s['timeout'] === 0) { + $s['timeout'] = (int)($s['timeout'] ?? 30); + if ($s['timeout'] < 1 || $s['timeout'] > 300) { $s['timeout'] = 30; } if (!isset($s['auth_username'])) { @@ -339,16 +340,35 @@ private function getResolvedSettings(): array private function validateSettings(array $settings): void { if (empty($settings['host'])) { + throw new PrinterConfigException('Keine IP-Adresse konfiguriert'); + } + + if (!Protocol::isValid($settings['protocol'])) { throw new PrinterConfigException( - 'Netzwerkdrucker: Keine IP-Adresse/Hostname konfiguriert (Drucker-ID ' . $this->id . ')' + sprintf('Ungueltiges Protokoll: %s', $settings['protocol']) ); } - if (!Protocol::isValid($settings['protocol'])) { + // Port-Validierung + $port = (int)$settings['port']; + if ($port < 1 || $port > 65535) { + throw new PrinterConfigException( + sprintf('Ungueltiger Port: %d (erlaubt: 1-65535)', $port) + ); + } + + // Host-Validierung: Metadaten-Endpoints und Loopback blockieren + $host = $settings['host']; + $blockedHosts = ['169.254.169.254', 'metadata.google.internal', 'metadata']; + if (in_array(strtolower($host), $blockedHosts, true)) { throw new PrinterConfigException( - 'Netzwerkdrucker: Ungueltiges Protokoll "' . $settings['protocol'] . '" (Drucker-ID ' . $this->id . ')' + sprintf('Blockierter Host: %s', $host) ); } + // Loopback blockieren + if (preg_match('/^127\./', $host) || strtolower($host) === 'localhost' || $host === '::1') { + throw new PrinterConfigException('Loopback-Adressen sind nicht erlaubt'); + } } /** @@ -363,15 +383,23 @@ private function validateSettings(array $settings): void private function loadDocumentData(string $dokument): string { if (is_file($dokument)) { + // Dateien ueber 100 MB ablehnen (Speicherschutz) + $size = filesize($dokument); + if ($size !== false && $size > 104857600) { + throw new PrinterConfigException( + sprintf('Dokument zu gross: %d Bytes (max. 100 MB)', $size) + ); + } $content = file_get_contents($dokument); if ($content === false) { throw new PrinterConfigException( - 'Netzwerkdrucker: Datei konnte nicht gelesen werden: ' . $dokument + sprintf('Dokument nicht lesbar: %s', basename($dokument)) ); } return $content; } + // Rohdaten (z.B. ESC/POS-Stream direkt als String uebergeben) return $dokument; } diff --git a/www/lib/Printer/NetworkPrinter/Util/IppEncoder.php b/www/lib/Printer/NetworkPrinter/Util/IppEncoder.php index 5ad5f6add..0e247f2b3 100644 --- a/www/lib/Printer/NetworkPrinter/Util/IppEncoder.php +++ b/www/lib/Printer/NetworkPrinter/Util/IppEncoder.php @@ -375,9 +375,20 @@ public static function parseResponse(string $response): array */ private static function encodeAttribute(int $tag, string $name, string $value): string { - return pack('C', $tag) - . pack('n', strlen($name)) . $name - . pack('n', strlen($value)) . $value; + // IPP-Spec: Attribute-Werte duerfen max 1023 Bytes lang sein (name-without-language) + // pack('n', ...) erlaubt max 65535 — Overflow verhindern + if (strlen($value) > 65535) { + $value = substr($value, 0, 65535); + } + if (strlen($name) > 65535) { + $name = substr($name, 0, 65535); + } + + return chr($tag) + . pack('n', strlen($name)) + . $name + . pack('n', strlen($value)) + . $value; } /** From 5185cb5afa52ee140224b4260abd265622061cdb Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Tue, 7 Apr 2026 22:47:42 +0200 Subject: [PATCH 11/14] security(printer): robust SSRF protection with IP normalization, validate testConnection path - Replace string-based blocklist in validateSettings() with IP normalization via gethostbyname/ip2long/filter_var to block octal, hex, dword and IPv6-mapped addresses - Add validateSettings() call to testConnection() to close SSRF via test button - Remove raw OS error string ($errstr) from user-facing ConnectionTest message - Add 65535-byte truncation guard on IPP continuation attribute values in IppEncoder Co-Authored-By: Claude Sonnet 4.6 --- .../Printer/NetworkPrinter/NetworkPrinter.php | 56 +++++++++++++++++-- .../NetworkPrinter/Util/ConnectionTest.php | 4 +- .../NetworkPrinter/Util/IppEncoder.php | 3 + 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/www/lib/Printer/NetworkPrinter/NetworkPrinter.php b/www/lib/Printer/NetworkPrinter/NetworkPrinter.php index 508630654..50a649def 100644 --- a/www/lib/Printer/NetworkPrinter/NetworkPrinter.php +++ b/www/lib/Printer/NetworkPrinter/NetworkPrinter.php @@ -245,6 +245,17 @@ public function testConnection(): array { $settings = $this->getResolvedSettings(); + // Validierung auch im Test-Pfad erzwingen (SSRF-Schutz) + try { + $this->validateSettings($settings); + } catch (PrinterConfigException $e) { + return [ + 'success' => false, + 'message' => $e->getMessage(), + 'details' => [], + ]; + } + $test = new ConnectionTest(); return $test->test($settings); } @@ -357,17 +368,50 @@ private function validateSettings(array $settings): void ); } - // Host-Validierung: Metadaten-Endpoints und Loopback blockieren + // Host-Validierung: Loopback und Metadaten-Endpoints blockieren $host = $settings['host']; - $blockedHosts = ['169.254.169.254', 'metadata.google.internal', 'metadata']; - if (in_array(strtolower($host), $blockedHosts, true)) { + + // Bekannte Metadaten-Hostnames blockieren + $blockedHostnames = ['metadata.google.internal', 'metadata']; + if (in_array(strtolower($host), $blockedHostnames, true)) { throw new PrinterConfigException( sprintf('Blockierter Host: %s', $host) ); } - // Loopback blockieren - if (preg_match('/^127\./', $host) || strtolower($host) === 'localhost' || $host === '::1') { - throw new PrinterConfigException('Loopback-Adressen sind nicht erlaubt'); + + // Hostname zu IP aufloesen fuer Validierung + $resolvedIp = filter_var($host, FILTER_VALIDATE_IP) + ? $host + : gethostbyname($host); + + // Wenn Aufloesung fehlschlaegt, gibt gethostbyname den Input zurueck + if ($resolvedIp === $host && !filter_var($host, FILTER_VALIDATE_IP)) { + throw new PrinterConfigException( + sprintf('Hostname nicht aufloesbar: %s', $host) + ); + } + + // IP normalisieren und pruefen + $ipLong = ip2long($resolvedIp); + if ($ipLong !== false) { + // Loopback: 127.0.0.0/8 + if (($ipLong >> 24) === 127) { + throw new PrinterConfigException('Loopback-Adressen sind nicht erlaubt'); + } + // Link-local: 169.254.0.0/16 + if (($ipLong >> 16) === (169 * 256 + 254)) { + throw new PrinterConfigException('Link-Local-Adressen sind nicht erlaubt'); + } + } else { + // IPv6 pruefen + $lowerHost = strtolower($resolvedIp); + if ($lowerHost === '::1' + || strpos($lowerHost, '::ffff:127.') === 0 + || strpos($lowerHost, '::ffff:7f') === 0 + || $lowerHost === '::ffff:169.254.169.254' + ) { + throw new PrinterConfigException('Loopback/Link-Local IPv6-Adressen sind nicht erlaubt'); + } } } diff --git a/www/lib/Printer/NetworkPrinter/Util/ConnectionTest.php b/www/lib/Printer/NetworkPrinter/Util/ConnectionTest.php index e2671c370..d04d1fc7a 100644 --- a/www/lib/Printer/NetworkPrinter/Util/ConnectionTest.php +++ b/www/lib/Printer/NetworkPrinter/Util/ConnectionTest.php @@ -51,8 +51,8 @@ public function test(array $settings): array if ($fp === false) { $result['message'] = sprintf( - 'Drucker nicht erreichbar: %s:%d — %s (%d)', - $host, $port, $errstr, $errno + 'Drucker nicht erreichbar: %s:%d', + $host, $port ); return $result; } diff --git a/www/lib/Printer/NetworkPrinter/Util/IppEncoder.php b/www/lib/Printer/NetworkPrinter/Util/IppEncoder.php index 0e247f2b3..1551c8581 100644 --- a/www/lib/Printer/NetworkPrinter/Util/IppEncoder.php +++ b/www/lib/Printer/NetworkPrinter/Util/IppEncoder.php @@ -153,6 +153,9 @@ public static function buildGetPrinterAttributesRequest(string $printerUri, arra } else { // Additional values: value-tag + name-length=0 + value $val = (string)$attrName; + if (strlen($val) > 65535) { + $val = substr($val, 0, 65535); + } $body .= pack('C', self::TAG_KEYWORD); $body .= pack('n', 0); // name-length = 0 (additional value) $body .= pack('n', strlen($val)) . $val; From 16bf443c79bf5aeaf46aec280c51c353bb50ddaa Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Wed, 8 Apr 2026 00:08:31 +0200 Subject: [PATCH 12/14] fix(printer): replace form-submit test button with inline TCP status check The test_connection button used typ=submit which created a separate
that triggered OpenXE's POST-Redirect-GET pattern, losing the POST data. Replaced with typ=custom + renderTestButton() that performs an inline TCP connect check during page render. Shows green/red status indicator with response time. Points user to existing "Testseite drucken" button for actual print testing. --- .../Printer/NetworkPrinter/NetworkPrinter.php | 86 ++++++++++--------- 1 file changed, 45 insertions(+), 41 deletions(-) diff --git a/www/lib/Printer/NetworkPrinter/NetworkPrinter.php b/www/lib/Printer/NetworkPrinter/NetworkPrinter.php index 50a649def..69704f772 100644 --- a/www/lib/Printer/NetworkPrinter/NetworkPrinter.php +++ b/www/lib/Printer/NetworkPrinter/NetworkPrinter.php @@ -160,8 +160,8 @@ public function SettingsStructure(): array 'default' => '1', ], 'test_connection' => [ - 'typ' => 'submit', - 'text' => 'Verbindung testen', + 'typ' => 'custom', + 'function' => 'renderTestButton', ], ]; } @@ -261,52 +261,56 @@ public function testConnection(): array } /** - * Rendert die Einstellungsseite. Behandelt den "Verbindung testen"-Button - * vor dem Aufruf der Parent-Implementierung. + * Rendert den Verbindungsstatus als Inline-Check. + * Wird von PrinterBase::Settings() ueber typ=custom aufgerufen. + * Prueft beim Seitenaufbau ob der Drucker per TCP erreichbar ist + * und zeigt das Ergebnis sofort an. * - * @param string $target Template-Target oder 'return' - * @param array|null $struktur Optionale Struktur-Ueberschreibung - * - * @return string|null HTML-Output + * @return string HTML mit Status-Anzeige */ - public function Settings($target = 'return', $struktur = null) + public function renderTestButton(): string { - $testHtml = ''; - - if (isset($this->app->Secure) && $this->app->Secure->GetPOST('test_connection')) { - $result = $this->testConnection(); - - $color = $result['success'] ? '#d4edda' : '#f8d7da'; - $border = $result['success'] ? '#c3e6cb' : '#f5c6cb'; - $icon = $result['success'] ? '✓' : '✗'; - - $testHtml = '
'; - $testHtml .= '' . $icon . ' ' . htmlspecialchars($result['message']) . ''; - - if (!empty($result['details'])) { - $testHtml .= '
    '; - foreach ($result['details'] as $key => $val) { - $testHtml .= '
  • ' . htmlspecialchars($key) . ': '; - $testHtml .= htmlspecialchars((string)$val) . '
  • '; - } - $testHtml .= '
'; - } - - $testHtml .= '
'; + $settings = $this->getResolvedSettings(); + $host = $settings['host'] ?? ''; + $port = (int)($settings['port'] ?? 9100); + + if ($host === '') { + return '
' + . 'Hinweis: Bitte IP-Adresse eingeben und speichern.' + . '
'; } - $parentHtml = parent::Settings($target, $struktur); - - if ($testHtml !== '') { - if ($target !== 'return' && isset($this->app->Tpl)) { - $this->app->Tpl->Add($target, $testHtml); - } else { - return $testHtml . (string)$parentHtml; - } + // Schneller TCP-Connect-Check (max 3s) + $startTime = microtime(true); + $fp = @stream_socket_client( + sprintf('tcp://%s:%d', $host, $port), + $errno, $errstr, 3 + ); + $connectTime = round((microtime(true) - $startTime) * 1000); + + $html = ''; + if ($fp !== false) { + fclose($fp); + $html .= '
' + . '✓ Drucker erreichbar — ' + . htmlspecialchars($host) . ':' . $port + . ' (' . $connectTime . 'ms)' + . '
'; + } else { + $html .= '
' + . '✗ Drucker nicht erreichbar — ' + . htmlspecialchars($host) . ':' . $port + . '
'; } - return $parentHtml; + $html .= '
' + . 'Testdruck: Button "Testseite drucken" oben bei Anbindung verwenden.' + . '
'; + + return $html; } /** From d7cdfe7c30a904ee37f2b2dafdc8716378da8e23 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Wed, 8 Apr 2026 13:49:24 +0200 Subject: [PATCH 13/14] feat(printer): add 2-up batch mode with configurable rotation New opt-in feature to combine two A5 labels onto a single A4 sheet (label 1 on top half, label 2 on bottom half, portrait A4). Settings (per printer, all default off): - batch_2up: master switch to enable batching - batch_timeout: seconds to wait for second label (default 30) - batch_rotation: auto | none | cw | ccw (default auto) Behavior when batch_2up is enabled: - First label is stored in file-based queue (/tmp/np_batch_*) - Second label within timeout: both combined on one A4 sheet - Single label after timeout: printed alone on top half of A4 - Per-user + per-printer queue isolation Uses existing FPDI (www/lib/pdf/fpdi.php) for PDF import, no new dependencies. Labels are auto-scaled to fit target area while preserving aspect ratio; rotation handles portrait/landscape mismatch. Tested with Kyocera ECOSYS M5526cdw via IPP, A4 paper with 2x A5 adhesive labels. --- .../Printer/NetworkPrinter/NetworkPrinter.php | 108 +++++ .../NetworkPrinter/Util/PdfBatcher.php | 406 ++++++++++++++++++ 2 files changed, 514 insertions(+) create mode 100644 www/lib/Printer/NetworkPrinter/Util/PdfBatcher.php diff --git a/www/lib/Printer/NetworkPrinter/NetworkPrinter.php b/www/lib/Printer/NetworkPrinter/NetworkPrinter.php index 69704f772..048e832f9 100644 --- a/www/lib/Printer/NetworkPrinter/NetworkPrinter.php +++ b/www/lib/Printer/NetworkPrinter/NetworkPrinter.php @@ -14,6 +14,7 @@ require_once __DIR__ . '/Driver/LprDriver.php'; require_once __DIR__ . '/Status/StatusMonitor.php'; require_once __DIR__ . '/Util/ConnectionTest.php'; +require_once __DIR__ . '/Util/PdfBatcher.php'; /** * Netzwerkdrucker-Plugin fuer OpenXE. @@ -159,6 +160,33 @@ public function SettingsStructure(): array 'bezeichnung' => 'Automatischer Schnitt', 'default' => '1', ], + 'batch_2up' => [ + 'typ' => 'checkbox', + 'bezeichnung' => '2 Labels pro A4-Blatt', + 'default' => '', + 'info' => '2 A5-Labels zu einem A4-Blatt zusammenfassen (Hochformat, Label oben + Label unten).', + 'heading' => 'Batch-Druck (nur A4-Dokumentendrucker)', + ], + 'batch_timeout' => [ + 'typ' => 'text', + 'bezeichnung' => 'Wartezeit auf zweites Label (Sek.)', + 'placeholder' => '30', + 'size' => 5, + 'default' => '30', + 'info' => 'Nach dieser Zeit wird ein einzelnes Label allein auf der oberen Haelfte des A4-Blattes gedruckt.', + ], + 'batch_rotation' => [ + 'typ' => 'select', + 'bezeichnung' => 'Label-Rotation', + 'optionen' => [ + 'auto' => 'Automatisch (Hochformat-Labels drehen)', + 'none' => 'Keine Rotation', + 'cw' => '90 Grad im Uhrzeigersinn', + 'ccw' => '90 Grad gegen den Uhrzeigersinn', + ], + 'default' => 'auto', + 'info' => 'Automatisch dreht nur wenn Quell-Orientierung nicht zur A4-Haelfte passt. Teste mit "auto" zuerst.', + ], 'test_connection' => [ 'typ' => 'custom', 'function' => 'renderTestButton', @@ -180,12 +208,38 @@ public function printDocument($dokument, $anzahl): bool $settings = $this->getResolvedSettings(); $this->validateSettings($settings); + // Batch-Modus: zwei A5-Labels auf ein A4-Blatt kombinieren + if (!empty($settings['batch_2up']) && is_file($dokument)) { + $batchResult = $this->processBatch($dokument, $settings); + if ($batchResult === null) { + // Label in Warteschlange gelegt — noch nicht physisch drucken + $this->logInfo( + sprintf( + 'Label in Batch-Warteschlange: Drucker-ID=%d, User=%d', + $this->id, + $this->getUserId() + ) + ); + return true; + } + // Batch fertig: kombiniertes oder einzelnes PDF wurde erzeugt + $dokument = $batchResult; + } + $data = $this->loadDocumentData($dokument); $driver = $this->createDriver($settings); $options = $this->buildPrintOptions($settings, (int)$anzahl); $driver->send($data, $options); + // Temporaere Batch-Output-Datei aufraeumen + if (!empty($settings['batch_2up']) + && strpos(basename($dokument), 'np_out_') === 0 + && is_file($dokument) + ) { + @unlink($dokument); + } + $this->logInfo( sprintf( 'Druckauftrag erfolgreich: Drucker-ID=%d, Protokoll=%s, Host=%s, Kopien=%d', @@ -451,6 +505,60 @@ private function loadDocumentData(string $dokument): string return $dokument; } + /** + * Verarbeitet ein Label im Batch-Modus. + * + * @param string $pdfPath + * @param array $settings + * + * @return string|null Pfad zum finalen PDF, oder null wenn gequeued + */ + private function processBatch(string $pdfPath, array $settings): ?string + { + $timeout = (int)($settings['batch_timeout'] ?? 30); + if ($timeout < 1) { + $timeout = 30; + } + + $rotation = isset($settings['batch_rotation']) ? (string)$settings['batch_rotation'] : 'auto'; + if (!in_array($rotation, ['auto', 'none', 'cw', 'ccw'], true)) { + $rotation = 'auto'; + } + + $queueDir = $this->getQueueDir(); + $userId = $this->getUserId(); + + $batcher = new PdfBatcher($queueDir, (int)$this->id, $userId, $timeout); + $batcher->setRotation($rotation); + + return $batcher->process($pdfPath); + } + + /** + * @return int + */ + private function getUserId(): int + { + if (isset($this->app->User) && method_exists($this->app->User, 'GetID')) { + return (int)$this->app->User->GetID(); + } + return 0; + } + + /** + * @return string + */ + private function getQueueDir(): string + { + if (isset($this->app->erp) && method_exists($this->app->erp, 'GetTMP')) { + $tmp = (string)$this->app->erp->GetTMP(); + if ($tmp !== '') { + return rtrim($tmp, '/\\'); + } + } + return sys_get_temp_dir(); + } + /** * Erzeugt den passenden Treiber anhand des konfigurierten Protokolls. * diff --git a/www/lib/Printer/NetworkPrinter/Util/PdfBatcher.php b/www/lib/Printer/NetworkPrinter/Util/PdfBatcher.php new file mode 100644 index 000000000..13813e096 --- /dev/null +++ b/www/lib/Printer/NetworkPrinter/Util/PdfBatcher.php @@ -0,0 +1,406 @@ +queueDir = rtrim($queueDir, '/\\'); + $this->printerId = $printerId; + $this->userId = $userId; + $this->timeout = $timeout; + } + + /** + * Setzt den Rotation-Modus fuer Label-Platzierung. + * + * @param string $rotation auto|none|cw|ccw + */ + public function setRotation(string $rotation): void + { + if (in_array($rotation, ['auto', 'none', 'cw', 'ccw'], true)) { + $this->rotation = $rotation; + } + } + + /** + * Verarbeitet ein Label und gibt den Pfad zum finalen PDF zurueck + * (entweder ein kombiniertes A4 mit 2 Labels oder null wenn das Label + * in die Queue gestellt wurde und noch nicht gedruckt werden soll). + * + * @param string $currentPdfPath Pfad zum aktuellen Label-PDF + * + * @return string|null Pfad zum zu druckenden PDF, oder null wenn Label gequeued wurde + */ + public function process(string $currentPdfPath): ?string + { + $pendingFile = $this->getPendingFile(); + $timestampFile = $this->getTimestampFile(); + + // Pruefen ob pending Label existiert + if (is_file($pendingFile) && is_file($timestampFile)) { + $pendingTs = (int)@file_get_contents($timestampFile); + $age = time() - $pendingTs; + + if ($age < $this->timeout) { + // Beide Labels kombinieren + $combined = $this->combineTwoLabels($pendingFile, $currentPdfPath); + $this->clearQueue(); + return $combined; + } + + // Stale: altes Label einzeln drucken lassen, aktuelles als neues pending + $staleSingle = $this->combineSingleLabel($pendingFile); + $this->storePending($currentPdfPath); + return $staleSingle; + } + + // Kein pending: aktuelles Label in Queue legen + $this->storePending($currentPdfPath); + return null; + } + + /** + * Gibt alle abgelaufenen Queue-Eintraege als Liste zurueck und + * leert diese Eintraege. Sollte periodisch oder per Cron aufgerufen + * werden um verwaiste Labels nach langer Inaktivitaet zu drucken. + * + * @return array Liste von [printer_id, user_id, pdf_path] Arrays + */ + public function flushStaleGlobal(): array + { + $flushed = []; + if (!is_dir($this->queueDir)) { + return $flushed; + } + + $handle = @opendir($this->queueDir); + if ($handle === false) { + return $flushed; + } + + $now = time(); + while (false !== ($file = readdir($handle))) { + if (substr($file, -3) !== '.ts') { + continue; + } + if (strpos($file, 'np_batch_') !== 0) { + continue; + } + $tsPath = $this->queueDir . DIRECTORY_SEPARATOR . $file; + $pdfPath = substr($tsPath, 0, -3) . '.pdf'; + + $ts = (int)@file_get_contents($tsPath); + if ($now - $ts < $this->timeout) { + continue; + } + if (!is_file($pdfPath)) { + @unlink($tsPath); + continue; + } + + // Dateiname parsen: np_batch_{printerId}_{userId}.ts + $base = basename($file, '.ts'); + $parts = explode('_', $base); + if (count($parts) !== 4) { + continue; + } + + $singlePdf = $this->combineSingleLabel($pdfPath); + $flushed[] = [ + 'printer_id' => (int)$parts[2], + 'user_id' => (int)$parts[3], + 'pdf_path' => $singlePdf, + ]; + + @unlink($pdfPath); + @unlink($tsPath); + } + closedir($handle); + + return $flushed; + } + + /** + * Leert die Queue fuer den aktuellen Drucker+User. + */ + public function clearQueue(): void + { + @unlink($this->getPendingFile()); + @unlink($this->getTimestampFile()); + } + + /** + * Speichert das aktuelle Label als pending in der Queue. + * + * @param string $pdfPath + */ + private function storePending(string $pdfPath): void + { + if (!is_dir($this->queueDir)) { + @mkdir($this->queueDir, 0755, true); + } + @copy($pdfPath, $this->getPendingFile()); + @file_put_contents($this->getTimestampFile(), (string)time()); + } + + /** + * Kombiniert zwei A5-Labels auf ein A4-Blatt (Hochformat). + * Label 1 oben, Label 2 unten. + * + * @param string $pdf1 Erstes Label-PDF + * @param string $pdf2 Zweites Label-PDF + * + * @return string Pfad zum kombinierten A4-PDF + */ + private function combineTwoLabels(string $pdf1, string $pdf2): string + { + $pdf = $this->createFpdi(); + $pdf->AddPage('P', 'A4'); + + // A4 Hochformat: 210 breit, 297 hoch + // Obere Haelfte: (0, 0) bis (210, 148.5) + // Untere Haelfte: (0, 148.5) bis (210, 297) + $this->placeLabelOnPage($pdf, $pdf1, 0, 0, 210, 148.5); + $this->placeLabelOnPage($pdf, $pdf2, 0, 148.5, 210, 148.5); + + $outPath = $this->makeOutputPath('combined'); + $pdf->Output($outPath, 'F'); + return $outPath; + } + + /** + * Erstellt ein A4-PDF mit einem einzelnen Label auf der oberen Haelfte. + * + * @param string $pdfPath Label-PDF + * + * @return string Pfad zum A4-PDF + */ + private function combineSingleLabel(string $pdfPath): string + { + $pdf = $this->createFpdi(); + $pdf->AddPage('P', 'A4'); + + $this->placeLabelOnPage($pdf, $pdfPath, 0, 0, 210, 148.5); + + $outPath = $this->makeOutputPath('single'); + $pdf->Output($outPath, 'F'); + return $outPath; + } + + /** + * Platziert ein importiertes PDF auf einer Zielflaeche des Dokuments. + * Quell-PDFs im Hochformat werden um 90 Grad gedreht, damit sie in + * eine Querformat-Zielflaeche (A5 Landscape) passen. + * + * @param object $pdf FPDI-Instanz + * @param string $srcPdf Pfad zum Quell-PDF + * @param float $x Ziel-X (oben links) + * @param float $y Ziel-Y (oben links) + * @param float $w Ziel-Breite + * @param float $h Ziel-Hoehe + */ + private function placeLabelOnPage($pdf, string $srcPdf, float $x, float $y, float $w, float $h): void + { + $pageCount = $pdf->setSourceFile($srcPdf); + if ($pageCount < 1) { + return; + } + $tpl = $pdf->ImportPage(1); + + $size = $pdf->getTemplateSize($tpl); + if (!is_array($size) || !isset($size['w'], $size['h']) || $size['w'] <= 0 || $size['h'] <= 0) { + // Fallback ohne Groessen-Info: direkt strecken + $pdf->useTemplate($tpl, $x, $y, $w, $h); + return; + } + + $srcW = (float)$size['w']; + $srcH = (float)$size['h']; + $srcIsPortrait = $srcH > $srcW; + $dstIsPortrait = $h > $w; + + // Rotation-Entscheidung basierend auf User-Einstellung + $rotationAngle = 0; + switch ($this->rotation) { + case 'none': + $rotationAngle = 0; + break; + case 'cw': + $rotationAngle = -90; + break; + case 'ccw': + $rotationAngle = 90; + break; + case 'auto': + default: + // Auto: drehen wenn Quell-Orientierung nicht zur Zielflaeche passt + if ($srcIsPortrait !== $dstIsPortrait) { + $rotationAngle = -90; + } + break; + } + + $needsRotation = ($rotationAngle !== 0); + + if ($needsRotation) { + // Nach 90-Grad-Rotation vertauschen sich Breite und Hoehe + $effectiveSrcW = $srcH; + $effectiveSrcH = $srcW; + } else { + $effectiveSrcW = $srcW; + $effectiveSrcH = $srcH; + } + + // Aspect Ratio beibehalten, in Zielflaeche einpassen + $srcRatio = $effectiveSrcW / $effectiveSrcH; + $dstRatio = $w / $h; + + if ($srcRatio > $dstRatio) { + $useW = $w; + $useH = $w / $srcRatio; + } else { + $useH = $h; + $useW = $h * $srcRatio; + } + $offsetX = ($w - $useW) / 2; + $offsetY = ($h - $useH) / 2; + + $placeX = $x + $offsetX; + $placeY = $y + $offsetY; + + if ($needsRotation && method_exists($pdf, 'Rotate')) { + // Rotation um den Mittelpunkt der Zielflaeche + $centerX = $placeX + $useW / 2; + $centerY = $placeY + $useH / 2; + + $pdf->Rotate($rotationAngle, $centerX, $centerY); + + // Nach Rotation muss das Template mit vertauschten Dimensionen + // platziert werden (ungedreht gesehen). + $unrotatedW = $useH; + $unrotatedH = $useW; + $rotatedX = $centerX - $unrotatedW / 2; + $rotatedY = $centerY - $unrotatedH / 2; + + $pdf->useTemplate($tpl, $rotatedX, $rotatedY, $unrotatedW, $unrotatedH); + + $pdf->Rotate(0); + } else { + $pdf->useTemplate($tpl, $placeX, $placeY, $useW, $useH); + } + } + + /** + * Erstellt eine neue FPDI-Instanz. + * Laedt die noetigen OpenXE-PDF-Klassen falls noch nicht geladen. + * + * Die Klassen-Hierarchie ist: + * SuperFPDF -> PDF_EPS -> PDF -> PDF_Rotate -> fpdi -> fpdf_tpl -> FPDFWAWISION + * + * @return object + */ + private function createFpdi() + { + if (!class_exists('PDF_EPS') || !class_exists('FPDFWAWISION')) { + $rootDir = dirname(dirname(dirname(dirname(dirname(__DIR__))))); + $pdfDir = $rootDir . '/www/lib/pdf'; + + // FPDFWAWISION muss vor fpdf_tpl.php geladen werden + if (!class_exists('FPDFWAWISION')) { + if (is_file($pdfDir . '/fpdf_3.php')) { + require_once $pdfDir . '/fpdf_3.php'; + } elseif (is_file($pdfDir . '/fpdf.php')) { + require_once $pdfDir . '/fpdf.php'; + } + } + + // Restliche Kette: fpdf_final -> rotation -> fpdi -> fpdf_tpl + if (!class_exists('PDF_EPS') && is_file($pdfDir . '/fpdf_final.php')) { + require_once $pdfDir . '/fpdf_final.php'; + } + } + + if (!class_exists('SuperFPDF')) { + $rootDir = dirname(dirname(dirname(dirname(dirname(__DIR__))))); + $superFpdfPath = $rootDir . '/www/lib/dokumente/class.superfpdf.php'; + if (is_file($superFpdfPath)) { + require_once $superFpdfPath; + } + } + + if (class_exists('SuperFPDF')) { + return new SuperFPDF('P', 'mm', 'A4'); + } + if (class_exists('PDF_EPS')) { + return new PDF_EPS('P', 'mm', 'A4'); + } + + throw new \RuntimeException('Keine FPDI-kompatible PDF-Klasse gefunden'); + } + + /** + * @return string + */ + private function getPendingFile(): string + { + return $this->queueDir . DIRECTORY_SEPARATOR + . 'np_batch_' . $this->printerId . '_' . $this->userId . '.pdf'; + } + + /** + * @return string + */ + private function getTimestampFile(): string + { + return $this->queueDir . DIRECTORY_SEPARATOR + . 'np_batch_' . $this->printerId . '_' . $this->userId . '.ts'; + } + + /** + * @param string $suffix + * @return string + */ + private function makeOutputPath(string $suffix): string + { + if (!is_dir($this->queueDir)) { + @mkdir($this->queueDir, 0755, true); + } + return $this->queueDir . DIRECTORY_SEPARATOR + . 'np_out_' . $this->printerId . '_' . $this->userId + . '_' . $suffix . '_' . uniqid('', true) . '.pdf'; + } +} From 6ef8adf677f4580ef5476c4d00bf0b79c2a60bba Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Wed, 8 Apr 2026 23:55:23 +0200 Subject: [PATCH 14/14] feat(networkprinter): add thin module wrapper for DI container Add classes/Modules/NetworkPrinter/Bootstrap.php plus a README that documents the wrapper. The NetworkPrinter library itself stays where it is under www/lib/Printer/NetworkPrinter/: it uses global class names and require_once chains, external callers rely on those paths, and the library is already pending upstream as openxe-org/openxe#257. The Bootstrap exposes a 'NetworkPrinterFactory' service via the OpenXE DI container so other modules (e.g. LexwareOffice or a future invoice-print integration) can resolve a configured NetworkPrinter instance without touching the require_once chain. Zero changes to the library itself. The wrapper is purely additive and survives an upstream merge of PR #257 unchanged because it only references paths, not symbols. Co-Authored-By: Claude Opus 4.6 (1M context) --- classes/Modules/NetworkPrinter/Bootstrap.php | 70 +++++++++++ classes/Modules/NetworkPrinter/README.md | 125 +++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 classes/Modules/NetworkPrinter/Bootstrap.php create mode 100644 classes/Modules/NetworkPrinter/README.md diff --git a/classes/Modules/NetworkPrinter/Bootstrap.php b/classes/Modules/NetworkPrinter/Bootstrap.php new file mode 100644 index 000000000..e51b7fbad --- /dev/null +++ b/classes/Modules/NetworkPrinter/Bootstrap.php @@ -0,0 +1,70 @@ + + */ + public static function registerServices(): array + { + return [ + 'NetworkPrinterFactory' => 'onInitNetworkPrinterFactory', + ]; + } + + /** + * Liefert eine Factory-Closure, die fuer eine gegebene Drucker-ID eine + * NetworkPrinter-Instanz baut. Die Klasse \NetworkPrinter erbt von + * \PrinterBase und erwartet als Constructor-Argumente ($app, $id) — die + * eigentlichen Verbindungsdaten (host, port, protocol, ...) zieht der + * Drucker selbst aus der Tabelle `drucker` per id. + * + * \NetworkPrinter ist global namespaced (kein Xentral\...\NetworkPrinter), + * darum hier root-namespaced referenziert. + * + * @return callable(int): \NetworkPrinter + */ + public static function onInitNetworkPrinterFactory(ContainerInterface $container): callable + { + require_once self::LIBRARY_ENTRY; + + /** @var ApplicationCore $app */ + $app = $container->get('LegacyApplication'); + + return static function (int $printerId) use ($app): \NetworkPrinter { + return new \NetworkPrinter($app, $printerId); + }; + } +} diff --git a/classes/Modules/NetworkPrinter/README.md b/classes/Modules/NetworkPrinter/README.md new file mode 100644 index 000000000..fd7ec454e --- /dev/null +++ b/classes/Modules/NetworkPrinter/README.md @@ -0,0 +1,125 @@ +# NetworkPrinter Module Wrapper + +Duenner OpenXE-Modul-Wrapper fuer die `NetworkPrinter`-Library, die unter +`www/lib/Printer/NetworkPrinter/` als Drucker-Plugin liegt. Dieser Wrapper +fuegt nichts an der Library hinzu — er macht sie lediglich ueber den OpenXE +DI-Container erreichbar, damit andere Module den Drucker per Service-Lookup +holen koennen, ohne den `require_once`-Pfad an mehreren Stellen zu duplizieren. + +## Architecture Overview + +OpenXE laedt Drucker-Plugins zur Laufzeit ueber +`pages/drucker.php::loadPrinterModul($modul, $id)` per direkter +`require_once`-Kette aus `www/lib/Printer//.php`. Die `NetworkPrinter`-Library +folgt diesem Schema: ein globaler Klassenname (`class NetworkPrinter extends +PrinterBase`), kein Composer, kein PSR-4-Namespace. Das ist Absicht — nur so +findet `loadPrinterModul()` die Klasse per Filesystem-Konvention. + +Dieser Modul-Wrapper liegt parallel dazu unter +`classes/Modules/NetworkPrinter/`. Er enthaelt keine Geschaeftslogik, sondern +nur einen `Bootstrap.php`, der per OpenXE-Service-Auto-Discovery (`registerServices()`) +die Library laedt und eine Factory-Closure als DI-Service registriert. + +### Warum kein Code-Umzug + +1. **Update-Sicherheit**: Die Library ist als Pull-Request + [openxe-org/openxe#257](https://github.com/openxe-org/openxe/pull/257) + bei upstream offen. Beim Merge wuerde ein parallel umgezogener Code-Stand + im Fork harte Konflikte erzeugen. +2. **Existierende Aufrufer**: `loadPrinterModul()` erwartet die Klasse in + `www/lib/Printer//.php`. Ein Umzug nach `classes/Modules/...` + wuerde das Plugin fuer den Drucker-Spooler unsichtbar machen. +3. **Globale Klassen**: Die Library nutzt root-namespaced + `class NetworkPrinter extends PrinterBase`. Ein nachtraegliches + `namespace Xentral\...` zu setzen wuerde die Auto-Loading-Konvention + brechen. + +Der Wrapper referenziert die Library ausschliesslich per Pfad, nicht per +Symbol-Import. Dadurch ueberlebt er einen upstream-Merge ohne Aenderung. + +## File Layout + +``` +classes/Modules/NetworkPrinter/ + Bootstrap.php # DI-Registrierung + Factory-Closure + README.md # diese Datei + +www/lib/Printer/NetworkPrinter/ (UNVERAENDERT, Plugin-Pfad) + NetworkPrinter.php # Hauptklasse, extends PrinterBase + PrinterType.php + Protocol.php + Driver/ + DriverInterface.php + EscPosDriver.php + IppDriver.php + LprDriver.php + RawDriver.php + Exception/ + PrinterException.php + PrinterCommunicationException.php + PrinterConfigException.php + PrinterConnectionException.php + PrinterProtocolException.php + Status/ + StatusMonitor.php + Util/ + ConnectionTest.php + IppEncoder.php + PdfBatcher.php +``` + +## Requirements + +- PHP 7.4 bis 8.5 +- Keine Composer-Dependencies +- Keine zusaetzlichen PHP-Extensions ueber das hinaus, was OpenXE selbst + voraussetzt (`sockets`, `openssl`, optional `snmp` fuer Status-Monitoring) + +## Usage + +```php +/** @var \Xentral\Core\DependencyInjection\ContainerInterface $container */ +$factory = $container->get('NetworkPrinterFactory'); + +// $printerId = id eines Eintrags in der Tabelle `drucker` mit anbindung='NetworkPrinter' +$printer = $factory($printerId); + +// Standard-OpenXE-Plugin-Methode aus PrinterBase / NetworkPrinter +$printer->printDocument($pdfPath, $copies); +``` + +Die Factory liefert eine `callable(int $printerId): \NetworkPrinter`. Die +eigentliche Verbindungs-Konfiguration (host, port, protocol, lpr_queue, +auth_*) zieht `NetworkPrinter` ueber `PrinterBase::getSettings()` selbst aus +der `drucker`-Tabelle anhand der ID — der Aufrufer muss nichts uebergeben +ausser der ID. + +## Installation + +1. Branch `feature/network-printer-module` auschecken (oder die Files manuell + nach `classes/Modules/NetworkPrinter/` kopieren). +2. Service-Cache invalidieren, damit OpenXE den neuen Bootstrap erkennt: + ``` + rm "{WFuserdata}/tmp/{WFdbname}/cache_services.php" + ``` + (`{WFuserdata}` und `{WFdbname}` aus `www/conf/_config.php`.) +3. Beim naechsten Request baut OpenXE den Service-Container neu auf und + `NetworkPrinterFactory` ist verfuegbar. + +Kein Datenbank-Migration-Step, kein Install-Script. Der Drucker-Eintrag in +der `drucker`-Tabelle wird wie bei allen Druckern ueber das OpenXE-Backend +unter Stammdaten -> Drucker -> Anbindung "Netzwerkdrucker (IP)" angelegt. + +## Status + +Dieser Modul-Wrapper ist update-safe. Die zugrunde liegende Library +(`www/lib/Printer/NetworkPrinter/`) ist bei upstream als Pull-Request +[openxe-org/openxe#257](https://github.com/openxe-org/openxe/pull/257) +offen. Wenn der PR gemerged wird, bleibt der Wrapper kompatibel, weil er nur +auf den Pfad `www/lib/Printer/NetworkPrinter/NetworkPrinter.php` und auf den +globalen Klassennamen `\NetworkPrinter` referenziert — beides bleibt nach +einem upstream-Merge unveraendert. + +## License + +Siehe OpenXE-Hauptlizenz im Root des Repositories.