├── runSonarqube ├── settings.gradle.kts ├── cups-cheet-sheet.txt ├── tool ├── A4-blank.pdf ├── A4-ten-pages.pdf ├── Letter-blank.pdf ├── printjob.ipptool └── printerattributes.ipptool ├── src ├── test │ ├── resources │ │ ├── printJob.response │ │ ├── invalidHpNameWithLanguage.response │ │ ├── invalidBrotherMediaTypeSupported.response │ │ ├── invalidXeroxMediaCol.response │ │ └── logging.properties │ └── kotlin │ │ └── de │ │ └── gmuth │ │ ├── io │ │ ├── FileExtension.kt │ │ ├── FileSavingOutputStream.kt │ │ ├── FileSavingInputStream.kt │ │ ├── ByteArraySavingInputStream.kt │ │ └── ByteArraySavingOutputStream.kt │ │ ├── log │ │ ├── ConsoleHandler.kt │ │ ├── Logging.kt │ │ ├── StdoutHandler.kt │ │ └── SimpleClassNameFormatter.kt │ │ └── ipp │ │ ├── core │ │ ├── IppOperationsTests.kt │ │ ├── IppResolutionTests.kt │ │ ├── IppStatusTests.kt │ │ ├── IppStringTests.kt │ │ ├── IppCollectionTests.kt │ │ ├── IppResponseTests.kt │ │ ├── IppRequestTests.kt │ │ ├── IppMessageTests.kt │ │ ├── IppAttributeTests.kt │ │ ├── IppTagTests.kt │ │ ├── IppAttributesGroupTests.kt │ │ └── IppDateTimeTests.kt │ │ ├── client │ │ ├── inspectPrinter.kt │ │ ├── issueNo11.kt │ │ ├── IppClientTests.kt │ │ ├── IppClientMock.kt │ │ ├── IppExchangeExceptionTests.kt │ │ ├── IppMediaTests.kt │ │ ├── issueNo3.kt │ │ └── CupsClientTests.kt │ │ ├── tool │ │ ├── toolPrintJob.kt │ │ └── IppTool.kt │ │ └── iana │ │ ├── IppRegistrationSection2Tests.kt │ │ └── CSVReader.kt └── main │ ├── resources │ ├── blank_A4.pdf │ └── blank_USLetter.pdf │ └── kotlin │ └── de │ └── gmuth │ └── ipp │ ├── core │ ├── IppString.kt │ ├── IppAttributeBuilder.kt │ ├── IppException.kt │ ├── IppResolution.kt │ ├── IppCollection.kt │ ├── IppResponse.kt │ ├── IppStatus.kt │ ├── IppTag.kt │ ├── IppRequest.kt │ ├── IppDateTime.kt │ └── IppOperation.kt │ ├── attributes │ ├── Orientation.kt │ ├── PrintQuality.kt │ ├── Sides.kt │ ├── MediaSourceProperties.kt │ ├── MultipleDocumentHandling.kt │ ├── PrinterState.kt │ ├── Media.kt │ ├── CommunicationChannel.kt │ ├── MediaSource.kt │ ├── DocumentFormat.kt │ ├── ColorMode.kt │ ├── JobState.kt │ ├── Compression.kt │ ├── MediaMargin.kt │ ├── Finishing.kt │ ├── MediaSize.kt │ ├── Marker.kt │ ├── MediaSizeSupported.kt │ ├── TemplateAttributes.kt │ ├── MediaColDatabase.kt │ ├── PrinterType.kt │ └── MediaCollection.kt │ ├── Manifest.kt │ ├── client │ ├── WhichJobs.kt │ ├── IppExchangeException.kt │ ├── HttpPostException.kt │ ├── IppConfig.kt │ ├── SSLHelper.kt │ ├── IppOperationException.kt │ ├── IppEventNotification.kt │ ├── IppRequestExchangedEvent.kt │ ├── IppDocument.kt │ └── IppValueSupport.kt │ └── iana │ ├── IppRegistrationsSection4.kt │ ├── IppRegistrationsSection6.kt │ └── CSVTable.kt ├── gradle ├── verification-keyring.gpg └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── printers ├── CUPS │ ├── Cups-Get-Default.ipp │ ├── Cups-Get-Printers.ipp │ └── Cups-Get-Default-Error.ipp ├── Simulated_Laser_Printer │ ├── Get-Jobs.ipp │ ├── Print-Job.ipp │ ├── Get-Job-Attributes.ipp │ ├── Get-Printer-Attributes.ipp │ ├── Print-Job.txt │ ├── Get-Jobs.txt │ └── Get-Job-Attributes.txt ├── Xerox_B210_Printer │ ├── 006-Cancel-Job.res │ ├── 002-Identify-Printer.res │ ├── printer-stopped-media-jam.res │ ├── 001-Get-Printer-Attributes.res │ ├── printer-stopped-toner-empty.res │ ├── 001-Get-Printer-Attributes.req │ ├── 003-Validate-Job.res │ ├── 006-Cancel-Job.req │ ├── 004-Print-Job.req │ ├── 007-Get-Job-Attributes.req │ ├── 002-Identify-Printer.req │ ├── 004-Print-Job.res │ ├── 005-Get-Jobs.req │ ├── 006-Cancel-Job.res.txt │ ├── 002-Identify-Printer.res.txt │ ├── 005-Get-Jobs.res │ ├── 003-Validate-Job.req │ ├── 001-Get-Printer-Attributes.req.txt │ ├── 006-Cancel-Job.req.txt │ ├── 004-Print-Job.req.txt │ ├── 007-Get-Job-Attributes.req.txt │ ├── 002-Identify-Printer.req.txt │ ├── 003-Validate-Job.res.txt │ ├── 005-Get-Jobs.req.txt │ ├── 004-Print-Job.res.txt │ ├── 005-Get-Jobs.res.txt │ ├── 003-Validate-Job.req.txt │ ├── 007-Get-Job-Attributes.res │ └── 007-Get-Job-Attributes.res.txt └── CUPS_HP_LaserJet_100_color_MFP_M175 │ ├── Get-Job-Attributes.ipp │ ├── Get-Printer-Attributes.ipp │ ├── Print-Job.ipp │ ├── Print-Job.txt │ ├── Get-Jobs.ipp │ ├── Get-Jobs.txt │ └── Get-Job-Attributes.txt ├── issues └── #10 │ ├── addprinter1.ipptool │ └── addprinter1.ipptool-ppd-error.txt ├── publishToSonatype ├── update_iana_files ├── ipptool.md ├── .gitignore ├── .github ├── FUNDING.yml └── workflows │ ├── publish.yml │ ├── analyse.yml │ ├── build.yml │ └── scorecard_ynl ├── gradle.properties ├── LICENSE └── gradlew.bat /runSonarqube: -------------------------------------------------------------------------------- 1 | ./gradlew test jacocoTestReport sonarqube -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "ipp-client" -------------------------------------------------------------------------------- /cups-cheet-sheet.txt: -------------------------------------------------------------------------------- 1 | # unable to connect to localhost:631 ? 2 | cupsctl WebInterface=yes 3 | -------------------------------------------------------------------------------- /tool/A4-blank.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmuth/ipp-client-kotlin/HEAD/tool/A4-blank.pdf -------------------------------------------------------------------------------- /tool/A4-ten-pages.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmuth/ipp-client-kotlin/HEAD/tool/A4-ten-pages.pdf -------------------------------------------------------------------------------- /tool/Letter-blank.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmuth/ipp-client-kotlin/HEAD/tool/Letter-blank.pdf -------------------------------------------------------------------------------- /src/test/resources/printJob.response: -------------------------------------------------------------------------------- 1 | Gattributes-charsetutf-8Astatus-message not-infected -------------------------------------------------------------------------------- /gradle/verification-keyring.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmuth/ipp-client-kotlin/HEAD/gradle/verification-keyring.gpg -------------------------------------------------------------------------------- /src/main/resources/blank_A4.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmuth/ipp-client-kotlin/HEAD/src/main/resources/blank_A4.pdf -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmuth/ipp-client-kotlin/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /printers/CUPS/Cups-Get-Default.ipp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmuth/ipp-client-kotlin/HEAD/printers/CUPS/Cups-Get-Default.ipp -------------------------------------------------------------------------------- /printers/CUPS/Cups-Get-Printers.ipp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmuth/ipp-client-kotlin/HEAD/printers/CUPS/Cups-Get-Printers.ipp -------------------------------------------------------------------------------- /src/main/resources/blank_USLetter.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmuth/ipp-client-kotlin/HEAD/src/main/resources/blank_USLetter.pdf -------------------------------------------------------------------------------- /printers/CUPS/Cups-Get-Default-Error.ipp: -------------------------------------------------------------------------------- 1 | Gattributes-charsetutf-8Hattributes-natural-languageenAstatus-messageNo default printer. -------------------------------------------------------------------------------- /printers/Simulated_Laser_Printer/Get-Jobs.ipp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmuth/ipp-client-kotlin/HEAD/printers/Simulated_Laser_Printer/Get-Jobs.ipp -------------------------------------------------------------------------------- /printers/Simulated_Laser_Printer/Print-Job.ipp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmuth/ipp-client-kotlin/HEAD/printers/Simulated_Laser_Printer/Print-Job.ipp -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/006-Cancel-Job.res: -------------------------------------------------------------------------------- 1 | Gattributes-charsetutf-8Hattributes-natural-languageen-usE printer-uriipp://xero.local -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/002-Identify-Printer.res: -------------------------------------------------------------------------------- 1 | Gattributes-charsetutf-8Hattributes-natural-languageen-usE printer-uriipp://xero.local -------------------------------------------------------------------------------- /src/test/resources/invalidHpNameWithLanguage.response: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmuth/ipp-client-kotlin/HEAD/src/test/resources/invalidHpNameWithLanguage.response -------------------------------------------------------------------------------- /printers/Simulated_Laser_Printer/Get-Job-Attributes.ipp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmuth/ipp-client-kotlin/HEAD/printers/Simulated_Laser_Printer/Get-Job-Attributes.ipp -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/printer-stopped-media-jam.res: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmuth/ipp-client-kotlin/HEAD/printers/Xerox_B210_Printer/printer-stopped-media-jam.res -------------------------------------------------------------------------------- /printers/Simulated_Laser_Printer/Get-Printer-Attributes.ipp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmuth/ipp-client-kotlin/HEAD/printers/Simulated_Laser_Printer/Get-Printer-Attributes.ipp -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/001-Get-Printer-Attributes.res: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmuth/ipp-client-kotlin/HEAD/printers/Xerox_B210_Printer/001-Get-Printer-Attributes.res -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/printer-stopped-toner-empty.res: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmuth/ipp-client-kotlin/HEAD/printers/Xerox_B210_Printer/printer-stopped-toner-empty.res -------------------------------------------------------------------------------- /src/test/resources/invalidBrotherMediaTypeSupported.response: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmuth/ipp-client-kotlin/HEAD/src/test/resources/invalidBrotherMediaTypeSupported.response -------------------------------------------------------------------------------- /printers/CUPS_HP_LaserJet_100_color_MFP_M175/Get-Job-Attributes.ipp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmuth/ipp-client-kotlin/HEAD/printers/CUPS_HP_LaserJet_100_color_MFP_M175/Get-Job-Attributes.ipp -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/001-Get-Printer-Attributes.req: -------------------------------------------------------------------------------- 1 |  Gattributes-charsetutf-8Hattributes-natural-languageen-usE printer-uriipp://xero.localBrequesting-user-namegmuth -------------------------------------------------------------------------------- /printers/CUPS_HP_LaserJet_100_color_MFP_M175/Get-Printer-Attributes.ipp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmuth/ipp-client-kotlin/HEAD/printers/CUPS_HP_LaserJet_100_color_MFP_M175/Get-Printer-Attributes.ipp -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/003-Validate-Job.res: -------------------------------------------------------------------------------- 1 | Gattributes-charsetutf-8Hattributes-natural-languageen-usE printer-uriipp://xero.localDprint-color-modecolorDmediaiso_a3_297x420mm -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/006-Cancel-Job.req: -------------------------------------------------------------------------------- 1 | Gattributes-charsetutf-8Hattributes-natural-languageen-usBrequesting-user-name ipp-inspectorE printer-uriipp://xero.local!job-id_ -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/004-Print-Job.req: -------------------------------------------------------------------------------- 1 | Gattributes-charsetutf-8Hattributes-natural-languageen-usE printer-uriipp://xero.localBrequesting-user-name ipp-inspectorBjob-name blank_A4.pdf -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/007-Get-Job-Attributes.req: -------------------------------------------------------------------------------- 1 |  Gattributes-charsetutf-8Hattributes-natural-languageen-usBrequesting-user-name ipp-inspectorE printer-uriipp://xero.local!job-id_ -------------------------------------------------------------------------------- /printers/Simulated_Laser_Printer/Print-Job.txt: -------------------------------------------------------------------------------- 1 | job-uri (uri) = ipp://SpaceBook-2.local.:8632/jobs/461881017 2 | job-id (integer) = 461881017 3 | job-state (enum) = pending 4 | job-state-reasons (1setOf keyword) = none 5 | -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/002-Identify-Printer.req: -------------------------------------------------------------------------------- 1 | <Gattributes-charsetutf-8Hattributes-natural-languageen-usE printer-uriipp://xero.localBrequesting-user-name ipp-inspectorDidentify-actionsflash -------------------------------------------------------------------------------- /printers/CUPS_HP_LaserJet_100_color_MFP_M175/Print-Job.ipp: -------------------------------------------------------------------------------- 1 | Gattributes-charsetutf-8Hattributes-natural-languageenEjob-uriipp://localhost:631/jobs/2366!job-id ># job-stateAjob-state-messageDjob-state-reasonsnone -------------------------------------------------------------------------------- /printers/CUPS_HP_LaserJet_100_color_MFP_M175/Print-Job.txt: -------------------------------------------------------------------------------- 1 | job-uri (uri) = ipp://localhost:631/jobs/2366 2 | job-id (integer) = 2366 3 | job-state (enum) = pending 4 | job-state-message (textWithoutLanguage) = 5 | job-state-reasons (1setOf keyword) = none 6 | -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/004-Print-Job.res: -------------------------------------------------------------------------------- 1 | Gattributes-charsetutf-8Hattributes-natural-languageen-usE printer-uriipp://xero.local!job-id_# job-stateDjob-state-reasonsjob-hold-until-specifiedEjob-uriipp://xero.local/Job-3679 -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.6-bin.zip 4 | networkTimeout=10000 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/io/FileExtension.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.io 2 | 3 | import de.gmuth.ipp.core.IppResponse 4 | import java.io.File 5 | 6 | fun File.toIppResponse(): IppResponse { 7 | val file = this 8 | return IppResponse().apply { 9 | read(file) 10 | } 11 | } -------------------------------------------------------------------------------- /src/test/resources/invalidXeroxMediaCol.response: -------------------------------------------------------------------------------- 1 | Gattributes-charsetutf-8Hattributes-natural-languageen-usE printer-uriipp://xero.local./ipp/print4 media-col!job-idV# job-stateDjob-state-reasonsjob-hold-until-specifiedEjob-uri#ipp://xero.local./ipp/print/Job-598 -------------------------------------------------------------------------------- /tool/printjob.ipptool: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ipptool -tv -f A4-blank.pdf ipp://localhost:8632/printers/laser 2 | { 3 | OPERATION Print-Job 4 | GROUP operation-attributes-tag 5 | ATTR charset attributes-charset utf-8 6 | ATTR naturalLanguage attributes-natural-language en 7 | ATTR uri printer-uri $uri 8 | FILE $filename 9 | } -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/005-Get-Jobs.req: -------------------------------------------------------------------------------- 1 |  2 | Gattributes-charsetutf-8Hattributes-natural-languageen-usE printer-uriipp://xero.localDrequested-attributesjob-idDjob-uriDjob-printer-uriD job-stateDjob-nameDjob-state-reasonsDjob-originating-user-nameBrequesting-user-name ipp-inspector -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/006-Cancel-Job.res.txt: -------------------------------------------------------------------------------- 1 | # File: 006-Cancel-Job.res.txt (decoded 107 raw IPP bytes) 2 | version 2.0 3 | successful-ok 4 | request-id 6 5 | operation-attributes-tag 6 | attributes-charset (charset) = utf-8 7 | attributes-natural-language (naturalLanguage) = en-us 8 | printer-uri (uri) = ipp://xero.local 9 | -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/002-Identify-Printer.res.txt: -------------------------------------------------------------------------------- 1 | # File: 002-Identify-Printer.res.txt (decoded 107 raw IPP bytes) 2 | version 2.0 3 | successful-ok 4 | request-id 2 5 | operation-attributes-tag 6 | attributes-charset (charset) = utf-8 7 | attributes-natural-language (naturalLanguage) = en-us 8 | printer-uri (uri) = ipp://xero.local 9 | -------------------------------------------------------------------------------- /printers/CUPS_HP_LaserJet_100_color_MFP_M175/Get-Jobs.ipp: -------------------------------------------------------------------------------- 1 | Gattributes-charsetutf-8Hattributes-natural-languageenEjob-printer-uri(ipp://localhost:631/printers/ColorJet_HPEjob-uriipp://localhost:631/jobs/2366Bjob-originating-user-namegmuthBjob-name A4-blank.pdf!job-id ># job-stateDjob-state-reasons job-printing -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/005-Get-Jobs.res: -------------------------------------------------------------------------------- 1 | Gattributes-charsetutf-8Hattributes-natural-languageen-usE printer-uriipp://xero.local!job-id_Ejob-uriipp://xero.local/Job-3679Ejob-printer-uriipp://xero.local# job-stateBjob-name blank_A4.pdfDjob-state-reasonsjob-hold-until-Bjob-originating-user-name ipp-inspector -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/003-Validate-Job.req: -------------------------------------------------------------------------------- 1 | Gattributes-charsetutf-8Hattributes-natural-languageen-usE printer-uriipp://xero.localBrequesting-user-name ipp-inspectorBjob-name 2 | ValidationIdocument-formatapplication/octet-streamDsidestwo-sided-short-edge# print-qualityDprint-color-modecolorDmediaiso_a3_297x420mm -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/core/IppString.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.core 2 | 3 | /** 4 | * Copyright (c) 2020-2023 Gerhard Muth 5 | */ 6 | 7 | // name or text value, optional language 8 | data class IppString(val text: String, val language: String? = null) { 9 | 10 | override fun toString() = "${if (language == null) "" else "[$language] "}$text" 11 | 12 | } -------------------------------------------------------------------------------- /printers/CUPS_HP_LaserJet_100_color_MFP_M175/Get-Jobs.txt: -------------------------------------------------------------------------------- 1 | job-printer-uri (uri) = ipp://localhost:631/printers/ColorJet_HP 2 | job-uri (uri) = ipp://localhost:631/jobs/2366 3 | job-originating-user-name (nameWithoutLanguage) = gmuth 4 | job-name (nameWithoutLanguage) = A4-blank.pdf 5 | job-id (integer) = 2366 6 | job-state (enum) = processing 7 | job-state-reasons (1setOf keyword) = job-printing 8 | -------------------------------------------------------------------------------- /printers/Simulated_Laser_Printer/Get-Jobs.txt: -------------------------------------------------------------------------------- 1 | job-state-reasons (1setOf keyword) = job-printing 2 | job-originating-user-name (nameWithoutLanguage) = gmuth 3 | job-name (nameWithoutLanguage) = A4-blank.pdf 4 | job-state (enum) = processing 5 | job-id (integer) = 461881017 6 | job-uri (uri) = ipp://SpaceBook-2.local.:8632/jobs/461881017 7 | job-printer-uri (uri) = ipp://SpaceBook-2.local.:8632/printers/laser 8 | -------------------------------------------------------------------------------- /tool/printerattributes.ipptool: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ipptool -tv ipp://SpaceBook-2.local/printers/ColorJet_HP 2 | #ipp://SpaceBook-2.local:631/printers/ColorJet_HP 3 | #ipp://localhost:8632/printers/laser 4 | { 5 | OPERATION Get-Printer-Attributes 6 | GROUP operation-attributes-tag 7 | ATTR charset attributes-charset utf-8 8 | ATTR language attributes-natural-language en 9 | ATTR uri printer-uri $uri 10 | } -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/001-Get-Printer-Attributes.req.txt: -------------------------------------------------------------------------------- 1 | # File: 001-Get-Printer-Attributes.req.txt (decoded 137 raw IPP bytes) 2 | version 2.0 3 | Get-Printer-Attributes 4 | request-id 1 5 | operation-attributes-tag 6 | attributes-charset (charset) = utf-8 7 | attributes-natural-language (naturalLanguage) = en-us 8 | printer-uri (uri) = ipp://xero.local 9 | requesting-user-name (nameWithoutLanguage) = gmuth 10 | -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/006-Cancel-Job.req.txt: -------------------------------------------------------------------------------- 1 | # File: 006-Cancel-Job.req.txt (decoded 160 raw IPP bytes) 2 | version 2.0 3 | Cancel-Job 4 | request-id 6 5 | operation-attributes-tag 6 | attributes-charset (charset) = utf-8 7 | attributes-natural-language (naturalLanguage) = en-us 8 | requesting-user-name (nameWithoutLanguage) = ipp-inspector 9 | printer-uri (uri) = ipp://xero.local 10 | job-id (integer) = 3679 11 | -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/004-Print-Job.req.txt: -------------------------------------------------------------------------------- 1 | # File: 004-Print-Job.req.txt (decoded 170 raw IPP bytes) 2 | version 2.0 3 | Print-Job 4 | request-id 4 5 | operation-attributes-tag 6 | attributes-charset (charset) = utf-8 7 | attributes-natural-language (naturalLanguage) = en-us 8 | printer-uri (uri) = ipp://xero.local 9 | requesting-user-name (nameWithoutLanguage) = ipp-inspector 10 | job-name (nameWithoutLanguage) = blank_A4.pdf 11 | -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/log/ConsoleHandler.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.log 2 | 3 | /** 4 | * Copyright (c) 2023 Gerhard Muth 5 | */ 6 | 7 | import java.io.OutputStream 8 | import java.util.logging.ConsoleHandler 9 | import java.util.logging.Level 10 | 11 | class ConsoleHandler(outputStream: OutputStream = System.out) : ConsoleHandler() { 12 | init { 13 | super.setOutputStream(outputStream) 14 | level = Level.ALL 15 | } 16 | } -------------------------------------------------------------------------------- /issues/#10/addprinter1.ipptool: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ipptool -tv ipps://localhost/printers/x3 2 | { 3 | OPERATION Cups-Add-Modify-Printer 4 | GROUP operation-attributes-tag 5 | ATTR charset attributes-charset utf-8 6 | ATTR language attributes-natural-language en 7 | ATTR uri printer-uri $uri 8 | ATTR uri device-uri ipp://192.168.2.145 9 | ATTR text printer-info x3_Info 10 | ATTR text printer-location x3_Location 11 | ATTR name ppd-name everywhere 12 | } -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/007-Get-Job-Attributes.req.txt: -------------------------------------------------------------------------------- 1 | # File: 007-Get-Job-Attributes.req.txt (decoded 160 raw IPP bytes) 2 | version 2.0 3 | Get-Job-Attributes 4 | request-id 7 5 | operation-attributes-tag 6 | attributes-charset (charset) = utf-8 7 | attributes-natural-language (naturalLanguage) = en-us 8 | requesting-user-name (nameWithoutLanguage) = ipp-inspector 9 | printer-uri (uri) = ipp://xero.local 10 | job-id (integer) = 3679 11 | -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/core/IppAttributeBuilder.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.core 2 | 3 | /** 4 | * Copyright (c) 2020-2023 Gerhard Muth 5 | */ 6 | 7 | fun interface IppAttributeBuilder { 8 | 9 | // because some attribute names or values depend on the printers capabilities 10 | // we provide the printer attributes here (see also IppColorMode). 11 | fun buildIppAttribute(printerAttributes: IppAttributesGroup): IppAttribute<*> 12 | 13 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/core/IppException.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.core 2 | 3 | /** 4 | * Copyright (c) 2020-2024 Gerhard Muth 5 | */ 6 | 7 | open class IppException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) { 8 | 9 | class IppAttributeNotFoundException(val attributeName: String, val groupTag: IppTag) : 10 | IppException("attribute '$attributeName' not found in ${groupTag.name} group") 11 | 12 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/attributes/Orientation.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.attributes 2 | 3 | /** 4 | * Copyright (c) 2020-2025 Gerhard Muth 5 | */ 6 | 7 | enum class Orientation(val code: Int) { 8 | Portrait(3), 9 | Landscape(4), 10 | ReverseLandscape(5), 11 | ReversePortrait(6), 12 | None(7); // PWG 5100.13 13 | 14 | companion object { 15 | fun fromInt(code: Int) = values().single { it.code == code } 16 | } 17 | } -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/002-Identify-Printer.req.txt: -------------------------------------------------------------------------------- 1 | # File: 002-Identify-Printer.req.txt (decoded 171 raw IPP bytes) 2 | version 2.0 3 | Identify-Printer 4 | request-id 2 5 | operation-attributes-tag 6 | attributes-charset (charset) = utf-8 7 | attributes-natural-language (naturalLanguage) = en-us 8 | printer-uri (uri) = ipp://xero.local 9 | requesting-user-name (nameWithoutLanguage) = ipp-inspector 10 | identify-actions (1setOf keyword) = flash 11 | -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/log/Logging.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.log 2 | 3 | /** 4 | * Copyright (c) 2023 Gerhard Muth 5 | */ 6 | 7 | import java.util.* 8 | import java.util.logging.LogManager 9 | 10 | object Logging { 11 | fun configure() { 12 | Locale.setDefault(Locale.ENGLISH) // -Duser.language=en 13 | LogManager.getLogManager() 14 | .readConfiguration(Logging::class.java.getResourceAsStream("/logging.properties")) 15 | } 16 | } -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/003-Validate-Job.res.txt: -------------------------------------------------------------------------------- 1 | # File: 003-Validate-Job.res.txt (decoded 160 raw IPP bytes) 2 | version 2.0 3 | successful-ok-ignored-or-substituted-attributes 4 | request-id 3 5 | operation-attributes-tag 6 | attributes-charset (charset) = utf-8 7 | attributes-natural-language (naturalLanguage) = en-us 8 | printer-uri (uri) = ipp://xero.local 9 | unsupported-attributes-tag 10 | print-color-mode (keyword) = color 11 | media (keyword) = iso_a3_297x420mm 12 | -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/ipp/core/IppOperationsTests.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.core 2 | 3 | /** 4 | * Copyright (c) 2020-2024 Gerhard Muth 5 | */ 6 | 7 | import kotlin.test.Test 8 | import kotlin.test.assertEquals 9 | import kotlin.test.assertFailsWith 10 | 11 | class IppOperationsTests { 12 | 13 | @Test 14 | fun unknownOperationCodeFails() { 15 | val operation = IppOperation.fromInt(0) 16 | assertEquals(IppOperation.Unknown, operation) 17 | } 18 | 19 | } -------------------------------------------------------------------------------- /publishToSonatype: -------------------------------------------------------------------------------- 1 | 2 | # Checklist: 3 | # - run all tests 4 | # - validate sonar cloud quality gate 5 | # - change userAgent in IppConfig.kt 6 | # - change version in build.gradle.kts 7 | # - change versions in README.md: title and artifact-id 8 | 9 | gradlew -Drepo=sonatype publish 10 | 11 | # (gradlew --refresh-dependencies --write-verification-metadata pgp,sha256) 12 | 13 | # - use https://central.sonatype.com/publishing/deployments 14 | # - check https://repo1.maven.org/maven2/de/gmuth/ipp-client/3.4/ 15 | -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/log/StdoutHandler.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.log 2 | 3 | /** 4 | * Copyright (c) 2023 Gerhard Muth 5 | */ 6 | 7 | import java.util.logging.Level 8 | import java.util.logging.LogRecord 9 | import java.util.logging.StreamHandler 10 | 11 | class StdoutHandler() : StreamHandler(System.out, SimpleClassNameFormatter()) { 12 | 13 | init { 14 | level = Level.ALL 15 | } 16 | 17 | override fun publish(record: LogRecord?) { 18 | super.publish(record) 19 | flush() 20 | } 21 | } -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/005-Get-Jobs.req.txt: -------------------------------------------------------------------------------- 1 | # File: 005-Get-Jobs.req.txt (decoded 287 raw IPP bytes) 2 | version 2.0 3 | Get-Jobs 4 | request-id 5 5 | operation-attributes-tag 6 | attributes-charset (charset) = utf-8 7 | attributes-natural-language (naturalLanguage) = en-us 8 | printer-uri (uri) = ipp://xero.local 9 | requested-attributes (1setOf keyword) = job-id,job-uri,job-printer-uri,job-state,job-name,job-state-reasons,job-originating-user-name 10 | requesting-user-name (nameWithoutLanguage) = ipp-inspector 11 | -------------------------------------------------------------------------------- /update_iana_files: -------------------------------------------------------------------------------- 1 | cd src/main/resources 2 | 3 | # curl -o $FILE -z $FILE https://www.iana.org/assignments/ipp-registrations/$FILE 4 | # curl -o $FILE https://www.iana.org/assignments/ipp-registrations/$FILE 5 | 6 | IPP_REGISTRATIONS="https://www.iana.org/assignments/ipp-registrations" 7 | 8 | FILE=ipp-registrations-2.csv 9 | curl -o $FILE $IPP_REGISTRATIONS/$FILE 10 | 11 | FILE=ipp-registrations-4.csv 12 | curl -o $FILE $IPP_REGISTRATIONS/$FILE 13 | 14 | FILE=ipp-registrations-6.csv 15 | curl -o $FILE $IPP_REGISTRATIONS/$FILE 16 | -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/004-Print-Job.res.txt: -------------------------------------------------------------------------------- 1 | # File: 004-Print-Job.res.txt (decoded 224 raw IPP bytes) 2 | version 2.0 3 | successful-ok 4 | request-id 4 5 | operation-attributes-tag 6 | attributes-charset (charset) = utf-8 7 | attributes-natural-language (naturalLanguage) = en-us 8 | printer-uri (uri) = ipp://xero.local 9 | job-attributes-tag 10 | job-id (integer) = 3679 11 | job-state (enum) = pending-held 12 | job-state-reasons (1setOf keyword) = job-hold-until-specified 13 | job-uri (uri) = ipp://xero.local/Job-3679 14 | -------------------------------------------------------------------------------- /ipptool.md: -------------------------------------------------------------------------------- 1 | ### IppTool 2 | 3 | has very limited tag support (only charset, language and uri). If you like this API let me know. 4 | ```kotlin 5 | with(IppTool()) { 6 | uri = URI.create("ipp://colorjet.local/ipp/printer") 7 | filename = "A4-blank.pdf" 8 | interpret( 9 | "OPERATION Print-Job", 10 | "GROUP operation-attributes-tag", 11 | "ATTR charset attributes-charset utf-8", 12 | "ATTR language attributes-natural-language en", 13 | "ATTR uri printer-uri \$uri", 14 | "FILE \$filename" 15 | ) 16 | } 17 | ``` -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/ipp/client/inspectPrinter.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.client 2 | 3 | import de.gmuth.log.Logging 4 | import java.net.URI 5 | import java.util.logging.Level 6 | import java.util.logging.Logger 7 | 8 | fun main() { 9 | Logging.configure() 10 | val logger = Logger.getLogger("main") 11 | 12 | val printerURI = URI.create("ipp://xero.local") 13 | try { 14 | IppInspector().inspect(printerURI, cancelJob = true) 15 | } catch (throwable: Throwable) { 16 | logger.log(Level.SEVERE, "Failed to inspect printer $printerURI", throwable) 17 | } 18 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/attributes/PrintQuality.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.attributes 2 | 3 | /** 4 | * Copyright (c) 2020-2024 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppAttribute 8 | import de.gmuth.ipp.core.IppAttributeBuilder 9 | import de.gmuth.ipp.core.IppAttributesGroup 10 | import de.gmuth.ipp.core.IppTag.Enum 11 | 12 | enum class PrintQuality(val code: Int) : IppAttributeBuilder { 13 | 14 | Draft(3), Normal(4), High(5); 15 | 16 | override fun buildIppAttribute(printerAttributes: IppAttributesGroup) = 17 | IppAttribute("print-quality", Enum, code) 18 | 19 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /build/ 3 | 4 | # Ignore Gradle GUI config 5 | gradle-app.setting 6 | 7 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 8 | !gradle-wrapper.jar 9 | 10 | # Cache of project 11 | .gradletasknamecache 12 | 13 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 14 | # gradle/wrapper/gradle-wrapper.properties 15 | 16 | # Compiled class file 17 | *.class 18 | 19 | # Log file 20 | *.log 21 | 22 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 23 | hs_err_pid* 24 | 25 | # misc 26 | .DS_Store 27 | 28 | # Idea 29 | .idea 30 | /out -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/ipp/tool/toolPrintJob.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.tool 2 | 3 | import java.net.URI 4 | 5 | fun main() { 6 | with(IppTool()) { 7 | uri = URI.create("ipp://localhost:8632/printers/laser") 8 | filename = "tool/A4-blank.pdf" 9 | 10 | interpret( 11 | "OPERATION Print-Job", 12 | "GROUP operation-attributes-tag", 13 | "ATTR charset attributes-charset utf-8", 14 | "ATTR language attributes-natural-language en", 15 | "ATTR uri printer-uri \$uri", 16 | "FILE \$filename" 17 | ) 18 | } 19 | } -------------------------------------------------------------------------------- /issues/#10/addprinter1.ipptool-ppd-error.txt: -------------------------------------------------------------------------------- 1 | RECEIVED: 115 bytes in response 2 | status-code = server-error-internal-error (Unable to copy PPD file.) 3 | attributes-charset (charset) = utf-8 4 | attributes-natural-language (naturalLanguage) = en 5 | status-message (textWithoutLanguage) = Unable to copy PPD file. 6 | -- 7 | /var/log/cups/error_log: 8 | [cups-driverd] Unable to open \"/usr/share/cups/model/everywhere\" - No such file or directory 9 | copy_model: empty PPD file 10 | [Client 319] Returning IPP server-error-internal-error for CUPS-Add-Modify-Printer (ipps://localhost:631/printers/x3) from localhost. 11 | -- 12 | CUPS 2.3.4 on macOS 12.6.1 13 | -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/io/FileSavingOutputStream.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.io 2 | 3 | import java.io.File 4 | import java.io.FileOutputStream 5 | import java.io.OutputStream 6 | 7 | /** 8 | * Copyright (c) 2020 Gerhard Muth 9 | */ 10 | 11 | class FileSavingOutputStream(val file: File, val outputStream: OutputStream) : OutputStream() { 12 | 13 | private val fileOutputStream = FileOutputStream(file) 14 | 15 | override fun write(byte: Int) { 16 | outputStream.write(byte) 17 | fileOutputStream.write(byte) 18 | } 19 | 20 | override fun close() { 21 | super.close() 22 | fileOutputStream.close() 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/Manifest.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp 2 | 3 | import de.gmuth.ipp.client.IppClient 4 | import java.util.jar.Manifest 5 | 6 | /** 7 | * Copyright (c) 2025 Gerhard Muth 8 | */ 9 | 10 | class Manifest { 11 | companion object { 12 | private val instance = Manifest(IppClient::class.java.getResourceAsStream("/META-INF/MANIFEST.MF")) 13 | val mainAttributes = instance.mainAttributes 14 | val mavenArtifactName = mainAttributes.getValue("Maven-Artifact-Name") 15 | val mavenArtifactGroup = mainAttributes.getValue("Maven-Artifact-Group") 16 | val mavenArtifactVersion = mainAttributes.getValue("Maven-Artifact-Version") 17 | } 18 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/attributes/Sides.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.attributes 2 | 3 | /** 4 | * Copyright (c) 2020-2023 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppAttribute 8 | import de.gmuth.ipp.core.IppAttributeBuilder 9 | import de.gmuth.ipp.core.IppAttributesGroup 10 | import de.gmuth.ipp.core.IppTag.Keyword 11 | 12 | enum class Sides(private val keyword: String) : IppAttributeBuilder { 13 | 14 | OneSided("one-sided"), 15 | TwoSidedLongEdge("two-sided-long-edge"), 16 | TwoSidedShortEdge("two-sided-short-edge"); 17 | 18 | override fun buildIppAttribute(printerAttributes: IppAttributesGroup) = 19 | IppAttribute("sides", Keyword, keyword) 20 | 21 | } -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/005-Get-Jobs.res.txt: -------------------------------------------------------------------------------- 1 | # File: 005-Get-Jobs.res.txt (decoded 319 raw IPP bytes) 2 | version 2.0 3 | successful-ok 4 | request-id 5 5 | operation-attributes-tag 6 | attributes-charset (charset) = utf-8 7 | attributes-natural-language (naturalLanguage) = en-us 8 | printer-uri (uri) = ipp://xero.local 9 | job-attributes-tag 10 | job-id (integer) = 3679 11 | job-uri (uri) = ipp://xero.local/Job-3679 12 | job-printer-uri (uri) = ipp://xero.local 13 | job-state (enum) = pending-held 14 | job-name (nameWithoutLanguage) = blank_A4.pdf 15 | job-state-reasons (1setOf keyword) = job-hold-until- 16 | job-originating-user-name (nameWithoutLanguage) = ipp-inspector 17 | -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/io/FileSavingInputStream.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.io 2 | 3 | import java.io.File 4 | import java.io.FileOutputStream 5 | import java.io.InputStream 6 | 7 | /** 8 | * Copyright (c) 2020 Gerhard Muth 9 | */ 10 | 11 | class FileSavingInputStream(val file: File, val inputStream: InputStream) : InputStream() { 12 | 13 | private val fileOutputStream = FileOutputStream(file) 14 | 15 | override fun read(): Int { 16 | val byte = inputStream.read() 17 | if (byte != -1) fileOutputStream.write(byte) 18 | return byte 19 | } 20 | 21 | override fun close() { 22 | super.close() 23 | fileOutputStream.close() 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/003-Validate-Job.req.txt: -------------------------------------------------------------------------------- 1 | # File: 003-Validate-Job.req.txt (decoded 317 raw IPP bytes) 2 | version 2.0 3 | Validate-Job 4 | request-id 3 5 | operation-attributes-tag 6 | attributes-charset (charset) = utf-8 7 | attributes-natural-language (naturalLanguage) = en-us 8 | printer-uri (uri) = ipp://xero.local 9 | requesting-user-name (nameWithoutLanguage) = ipp-inspector 10 | job-name (nameWithoutLanguage) = Validation 11 | document-format (mimeMediaType) = application/octet-stream 12 | job-attributes-tag 13 | sides (keyword) = two-sided-short-edge 14 | print-quality (enum) = normal 15 | print-color-mode (keyword) = color 16 | media (keyword) = iso_a3_297x420mm 17 | -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/007-Get-Job-Attributes.res: -------------------------------------------------------------------------------- 1 | Gattributes-charsetutf-8Hattributes-natural-languageen-usE printer-uriipp://xero.local!copies! number-up# print-qualityEjob-uriipp://xero.local/Job-3679!job-id_Bjob-name blank_A4.pdfBjob-originating-user-name ipp-inspector# job-stateEjob-uuid-urn:uuid:16a65700-007c-1000-bb49-e5f34eac2f55Djob-state-reasonsjob-canceled-by-user!time-at-completed7l)!time-at-creation7l)!time-at-processing! job-k-octets!job-impressions!job-k-octets-processed!job-impressions-completed!job-media-sheets-completedEjob-printer-uriipp://xero.local!job-printer-up-time7l -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/attributes/MediaSourceProperties.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.attributes 2 | 3 | import de.gmuth.ipp.core.IppCollection 4 | 5 | /** 6 | * Copyright (c) 2025 Gerhard Muth 7 | */ 8 | 9 | data class MediaSourceProperties(val feedOrientation: Orientation, val feedDirection: String) { 10 | 11 | override fun toString() = "{feed-orientation=${feedOrientation.name.lowercase()}, feed-direction=$feedDirection}" 12 | 13 | companion object { 14 | fun fromIppCollection(ippCollection: IppCollection) = MediaSourceProperties( 15 | Orientation.fromInt(ippCollection.getValue("media-source-feed-orientation")), 16 | ippCollection.getValue("media-source-feed-direction") 17 | ) 18 | } 19 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/client/WhichJobs.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.client 2 | 3 | /** 4 | * Copyright (c) 2021-2023 Gerhard Muth 5 | */ 6 | 7 | // PWG Job, Printer and shared Infrastructure Extensions 8 | enum class WhichJobs(val keyword: String) { 9 | // RFC 8011 10 | Completed("completed"), 11 | NotCompleted("not-completed"), 12 | 13 | // PWG 5100.7 14 | All("all"), 15 | Aborted("aborted"), 16 | Canceled("canceled"), 17 | Pending("pending"), 18 | Processing("processing"), 19 | PendingHeld("pending-held"), 20 | ProcessingStopped("processing-stopped"), 21 | 22 | // PWG 5100.11 23 | ProofPrint("proof-print"), 24 | Saved("saved"), 25 | 26 | // PWG 5100.18 27 | Fetchable("fetchable") 28 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [gmuth] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/io/ByteArraySavingInputStream.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.io 2 | 3 | /** 4 | * Copyright (c) 2020-2022 Gerhard Muth 5 | */ 6 | 7 | import java.io.ByteArrayOutputStream 8 | import java.io.InputStream 9 | 10 | class ByteArraySavingInputStream(private val inputStream: InputStream) : InputStream() { 11 | 12 | var saveBytes: Boolean = true 13 | private val byteArrayOutputStream = ByteArrayOutputStream() 14 | 15 | override fun read() = inputStream.read().also { 16 | if (saveBytes && it != -1) byteArrayOutputStream.write(it) 17 | } 18 | 19 | override fun close() { 20 | super.close() 21 | byteArrayOutputStream.close() 22 | } 23 | 24 | fun toByteArray(): ByteArray = byteArrayOutputStream.toByteArray() 25 | 26 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/io/ByteArraySavingOutputStream.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.io 2 | 3 | /** 4 | * Copyright (c) 2020-2022 Gerhard Muth 5 | */ 6 | 7 | import java.io.ByteArrayOutputStream 8 | import java.io.OutputStream 9 | 10 | class ByteArraySavingOutputStream(private val outputStream: OutputStream) : OutputStream() { 11 | 12 | var saveBytes: Boolean = true 13 | private val byteArrayOutputStream = ByteArrayOutputStream() 14 | 15 | override fun write(byte: Int) = outputStream.write(byte).also { 16 | if (saveBytes) byteArrayOutputStream.write(byte) 17 | } 18 | 19 | override fun close() { 20 | super.close() 21 | byteArrayOutputStream.close() 22 | } 23 | 24 | fun toByteArray(): ByteArray = byteArrayOutputStream.toByteArray() 25 | 26 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/ipp/core/IppResolutionTests.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.core 2 | 3 | /** 4 | * Copyright (c) 2020-2023 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppResolution.Unit.DPC 8 | import kotlin.test.Test 9 | import kotlin.test.assertEquals 10 | import kotlin.test.assertFailsWith 11 | 12 | class IppResolutionTests { 13 | 14 | @Test 15 | fun toStringTestDpc() { 16 | assertEquals("600 dpc", IppResolution(600, 600, DPC).toString()) 17 | } 18 | 19 | @Test 20 | fun toStringTestXdpi() { 21 | assertEquals("1200x600 dpi", IppResolution(1200, 600).toString()) 22 | } 23 | 24 | @Test 25 | fun failOnUnknownUnitCode() { 26 | assertFailsWith { 27 | IppResolution.Unit.fromInt(10) 28 | } 29 | } 30 | 31 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # solution for "sonarqube jvm out of memory" 2 | #org.gradle.jvmargs=-Xmx3g 3 | 4 | # https://docs.sonarsource.com/sonarqube/latest/analyzing-source-code/scanners/sonarscanner-for-gradle/#troubleshooting 5 | org.gradle.jvmargs=-XX:MetaspaceSize=512M -XX:MaxMetaspaceSize=512M 6 | 7 | # java -XX:+PrintFlagsFinal -version|grep MaxHeapSize 8 | # Oracle-arm 11.0.5: MaxHeapSize = 2147483648 9 | 10 | #org.gradle.daemon=false 11 | #org.gradle.warning.mode=all 12 | 13 | # sonarcloud 14 | systemProp.sonar.host.url=https://sonarcloud.io 15 | systemProp.sonar.projectKey=gmuth_ipp-client-kotlin 16 | systemProp.sonar.organization=gmuth 17 | #systemProp.sonar.gradle.skipCompile=true 18 | 19 | # dokka verification fails but javadoc is required for publishing. 20 | org.gradle.dependency.verification=off 21 | -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/attributes/MultipleDocumentHandling.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.attributes 2 | 3 | /** 4 | * Copyright (c) 2024 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppAttribute 8 | import de.gmuth.ipp.core.IppAttributeBuilder 9 | import de.gmuth.ipp.core.IppAttributesGroup 10 | import de.gmuth.ipp.core.IppTag.Keyword 11 | 12 | enum class MultipleDocumentHandling(val keyword: String) : IppAttributeBuilder { 13 | 14 | SeparateDocumentsUncollatedCopies("separate-documents-uncollated-copies"), 15 | SeparateDocumentsCollatedCopies("separate-documents-collated-copies"), 16 | SingleDocumentNewSheet("single-document-new-sheet"), 17 | SingleDocument("single-document"); 18 | 19 | override fun buildIppAttribute(printerAttributes: IppAttributesGroup) = 20 | IppAttribute("multiple-document-handling", Keyword, keyword) 21 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/core/IppResolution.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.core 2 | 3 | /** 4 | * Copyright (c) 2020-2023 Gerhard Muth 5 | */ 6 | 7 | // RFC 8011 5.1.16. 8 | data class IppResolution(val x: Int, val y: Int, val unit: Int) { 9 | 10 | constructor(x: Int, y: Int, unit: Unit = Unit.DPI) : this(x, y, unit.code) 11 | 12 | constructor(xy: Int, unit: Unit = Unit.DPI) : this(xy, xy, unit.code) 13 | 14 | override fun toString() = "$x${if (x == y) "" else "x$y"} ${Unit.fromInt(unit)}" 15 | 16 | enum class Unit(val code: Int) { 17 | DPI(3), DPC(4); 18 | 19 | override fun toString() = name.lowercase() 20 | 21 | companion object { 22 | fun fromInt(code: Int) = 23 | values().singleOrNull() { it.code == code } ?: throw IppException("Unknown unit code $code") 24 | } 25 | } 26 | 27 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/attributes/PrinterState.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.attributes 2 | 3 | import de.gmuth.ipp.core.IppAttribute 4 | import de.gmuth.ipp.core.IppAttributeBuilder 5 | import de.gmuth.ipp.core.IppAttributesGroup 6 | import de.gmuth.ipp.core.IppTag 7 | 8 | /** 9 | * Copyright (c) 2020-2023 Gerhard Muth 10 | */ 11 | 12 | enum class PrinterState(val code: Int) : IppAttributeBuilder { 13 | 14 | Idle(3), 15 | Processing(4), 16 | Stopped(5); 17 | 18 | // https://www.iana.org/assignments/ipp-registrations/ipp-registrations.xml#ipp-registrations-6 19 | override fun toString() = name.lowercase() 20 | 21 | override fun buildIppAttribute(printerAttributes: IppAttributesGroup) = 22 | IppAttribute("printer-state", IppTag.Enum, code) 23 | 24 | companion object { 25 | fun fromInt(code: Int) = values().single { it.code == code } 26 | } 27 | 28 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/log/SimpleClassNameFormatter.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.log 2 | 3 | /** 4 | * Copyright (c) 2023 Gerhard Muth 5 | */ 6 | 7 | import java.util.logging.LogManager 8 | import java.util.logging.LogRecord 9 | import java.util.logging.SimpleFormatter 10 | 11 | class SimpleClassNameFormatter( 12 | val simpleClassName: Boolean = getProperty("simpleClassName")?.toBooleanStrict() ?: true 13 | ) : SimpleFormatter() { 14 | 15 | companion object { 16 | private val logManager by lazy { LogManager.getLogManager() } 17 | private fun getProperty(name: String): String? = 18 | logManager.getProperty("${SimpleClassNameFormatter::class.java.canonicalName}.$name") 19 | } 20 | 21 | override fun format(logRecord: LogRecord?) = super.format( 22 | logRecord.apply { 23 | if (this != null && simpleClassName) loggerName = loggerName.substringAfterLast(".") 24 | } 25 | ) 26 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/ipp/client/issueNo11.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.client 2 | 3 | import java.net.URI 4 | import java.util.logging.Level.SEVERE 5 | import java.util.logging.Logger.getLogger 6 | 7 | fun main() { 8 | val logger = getLogger("issueNo11") 9 | 10 | //val printerUri = URI.create("ipp://192.168.31.244:631/ipp/print") 11 | val printerUri = URI.create("ipp://xero.local/ipp/print") 12 | 13 | try { 14 | logger.info { "open ipp connection to $printerUri" } 15 | with( 16 | IppPrinter( 17 | printerUri, 18 | //httpConfig = HttpClient.Config(debugLogging = true), 19 | //getPrinterAttributesOnInit = false 20 | ) 21 | ) { 22 | logger.info { "successfully connected $printerUri" } 23 | logger.info { toString() } 24 | markers.forEach { logger.info { it.toString() } } 25 | } 26 | } catch (exception: Exception) { 27 | logger.log(SEVERE, exception, { "failed to connect to $printerUri" }) 28 | } 29 | } -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Gradle 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle 3 | # https://docs.github.com/en/free-pro-team@latest/actions/reference/environment-variables 4 | 5 | name: Publish 6 | 7 | on: 8 | workflow_dispatch: 9 | # push: 10 | # branches: 11 | # - master 12 | # - develop 13 | 14 | jobs: 15 | build-and-publish: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout sources 19 | uses: actions/checkout@v3 20 | - name: Setup JDK 11 21 | uses: actions/setup-java@v3 22 | with: 23 | distribution: corretto 24 | java-version: 11 25 | - name: Gradle build 26 | uses: gradle/gradle-build-action@v2 27 | with: 28 | gradle-version: 7.5.1 29 | arguments: --console=plain build 30 | - name: Gradle publish 31 | env: 32 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 33 | run: gradle -Drepo=github publish 34 | -------------------------------------------------------------------------------- /printers/Simulated_Laser_Printer/Get-Job-Attributes.txt: -------------------------------------------------------------------------------- 1 | job-media-progress (integer) = 0 2 | job-impressions-completed (integer) = 1 3 | job-printer-up-time (integer) = 1630259029 4 | job-state-reasons (1setOf keyword) = job-canceled-by-user 5 | printer-uri (uri) = ipp://localhost:8632/printers/laser 6 | job-originating-user-name (nameWithoutLanguage) = gmuth 7 | job-name (nameWithoutLanguage) = A4-blank.pdf 8 | document-format (mimeMediaType) = application/pdf 9 | job-state (enum) = canceled 10 | time-at-completed (integer) = 1630259027 11 | time-at-creation (integer) = 1630259026 12 | time-at-processing (integer) = 1630259026 13 | job-id (integer) = 461881017 14 | job-uri (uri) = ipp://SpaceBook-2.local.:8632/jobs/461881017 15 | job-printer-uri (uri) = ipp://SpaceBook-2.local.:8632/printers/laser 16 | job-printer-name (nameWithoutLanguage) = laser 17 | job-printer-info (textWithoutLanguage) = Simulated Laser 18 | job-printer-kind (enum) = 3 19 | job-printer-location (textWithoutLanguage) = SpaceBook 20 | job-uuid (uri) = urn:uuid:df37dbf4-7dc4-328e-4e9c-3b1835bd49c0 21 | job-impressions (integer) = 1 22 | -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/ipp/core/IppStatusTests.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.core 2 | 3 | /** 4 | * Copyright (c) 2020 Gerhard Muth 5 | */ 6 | 7 | import kotlin.test.* 8 | 9 | class IppStatusTests { 10 | 11 | @Test 12 | fun isSuccessful() { 13 | assertTrue(IppStatus.SuccessfulOk.isSuccessful()) 14 | assertFalse(IppStatus.ClientErrorBadRequest.isSuccessful()) 15 | } 16 | 17 | @Test 18 | fun isClientError() { 19 | assertTrue(IppStatus.ClientErrorBadRequest.isClientError()) 20 | assertFalse(IppStatus.ServerErrorInternalError.isClientError()) 21 | } 22 | 23 | @Test 24 | fun isServerError() { 25 | assertTrue(IppStatus.ServerErrorInternalError.isServerError()) 26 | assertFalse(IppStatus.ClientErrorBadRequest.isServerError()) 27 | } 28 | 29 | @Test 30 | fun fromShort() { 31 | assertEquals(IppStatus.ClientErrorDocumentFormatNotSupported, IppStatus.fromInt(0x40A)) 32 | } 33 | 34 | @Test 35 | fun fromShortFails() { 36 | assertFailsWith { IppStatus.fromInt(10) } 37 | } 38 | 39 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/client/IppExchangeException.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.client 2 | 3 | /** 4 | * Copyright (c) 2020-2025 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppException 8 | import de.gmuth.ipp.core.IppRequest 9 | import java.nio.file.Path 10 | import java.util.logging.Level 11 | import java.util.logging.Level.INFO 12 | import java.util.logging.Logger 13 | import kotlin.io.path.createTempDirectory 14 | 15 | open class IppExchangeException( 16 | val request: IppRequest, 17 | message: String = "Exchanging request '${request.operation}' failed", 18 | cause: Throwable? = null 19 | ) : IppException(message, cause) { 20 | 21 | open fun log(logger: Logger, level: Level = INFO): Unit = with(logger) { 22 | log(level) { message } 23 | request.log(this, level, prefix = "REQUEST: ") 24 | } 25 | 26 | open fun saveMessages( 27 | directory: Path = createTempDirectory("ipp-client-"), 28 | fileNameWithoutSuffix: String = "ipp_exchange_exception" 29 | ) = 30 | request.saveBytes(directory.resolve("$fileNameWithoutSuffix.request")) 31 | 32 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/ipp/core/IppStringTests.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.core 2 | 3 | /** 4 | * Copyright (c) 2020-2023 Gerhard Muth 5 | */ 6 | 7 | import kotlin.test.Test 8 | import kotlin.test.assertEquals 9 | import kotlin.test.assertNull 10 | 11 | class IppStringTests { 12 | 13 | private val withoutLanguage = IppString("string-without-language") 14 | private val withLanguage = IppString("string-with-language", "en") 15 | 16 | @Test 17 | fun toStringWithoutLanguage() { 18 | assertEquals("string-without-language", withoutLanguage.toString()) 19 | } 20 | 21 | @Test 22 | fun toStringWithLanguage() { 23 | assertEquals("[en] string-with-language", withLanguage.toString()) 24 | } 25 | 26 | @Test 27 | fun toIppStringExtension() { 28 | assertEquals("string-without-language", withoutLanguage.text) 29 | assertNull(withoutLanguage.language) 30 | } 31 | 32 | @Test 33 | fun toIppStringExtensionWithLanguage() { 34 | assertEquals("string-with-language", withLanguage.text) 35 | assertEquals("en", withLanguage.language) 36 | } 37 | 38 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/attributes/Media.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.attributes 2 | 3 | /** 4 | * Copyright (c) 2020-2023 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppAttribute 8 | import de.gmuth.ipp.core.IppAttributeBuilder 9 | import de.gmuth.ipp.core.IppAttributesGroup 10 | import de.gmuth.ipp.core.IppTag.Keyword 11 | 12 | class Media(val keyword: String) : IppAttributeBuilder { 13 | 14 | companion object { 15 | @JvmField 16 | val ISO_A3 = Media("iso_a3_297x420mm") 17 | 18 | @JvmField 19 | val ISO_A4 = Media("iso_a4_210x297mm") 20 | 21 | @JvmField 22 | val ISO_A5 = Media("iso_a5_148x210mm") 23 | 24 | @JvmField 25 | val ISO_A6 = Media("iso_a6_105x148mm") 26 | 27 | @JvmField 28 | val NA_LEGAL = Media("na_legal_8.5x14in") 29 | 30 | @JvmField 31 | val NA_LETTER = Media("na_letter_8.5x11in") 32 | 33 | @JvmField 34 | val NA_LEDGER = Media("na_ledger_11x17in") 35 | } 36 | 37 | override fun buildIppAttribute(printerAttributes: IppAttributesGroup) = 38 | IppAttribute("media", Keyword, keyword) 39 | 40 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2024 Gerhard Muth 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/attributes/CommunicationChannel.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.attributes 2 | 3 | /** 4 | * Copyright (c) 2021-2023 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppAttributesGroup 8 | import java.net.URI 9 | 10 | // RFC 8011, page 26 11 | class CommunicationChannel( 12 | val uri: URI, 13 | val security: String, 14 | val authentication: String 15 | ) { 16 | override fun toString() = "$uri, security=$security, authentication=$authentication" 17 | 18 | companion object { 19 | fun getCommunicationChannelsSupported(attributes: IppAttributesGroup) = with(attributes) { 20 | val printerUriSupportedList = getValues>("printer-uri-supported") 21 | val uriSecuritySupportedList = getValues>("uri-security-supported") 22 | val uriAuthenticationSupportedList = getValues>("uri-authentication-supported") 23 | printerUriSupportedList.indices.map { 24 | CommunicationChannel( 25 | printerUriSupportedList[it], 26 | uriSecuritySupportedList[it], 27 | uriAuthenticationSupportedList[it] 28 | ) 29 | } 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/attributes/MediaSource.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.attributes 2 | 3 | /** 4 | * Copyright (c) 2020-2024 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppAttribute 8 | import de.gmuth.ipp.core.IppAttributeBuilder 9 | import de.gmuth.ipp.core.IppAttributesGroup 10 | import de.gmuth.ipp.core.IppTag.Keyword 11 | 12 | data class MediaSource(val keyword: String) : IppAttributeBuilder { 13 | 14 | companion object { 15 | @JvmField 16 | val Auto = MediaSource("auto") 17 | 18 | @JvmField 19 | val Main = MediaSource("main") 20 | 21 | @JvmField 22 | val Tray1 = MediaSource("tray-1") 23 | 24 | @JvmField 25 | val Manual = MediaSource("manual") 26 | 27 | @JvmField 28 | val Envelope = MediaSource("envelope") 29 | 30 | @JvmField 31 | val Alternate = MediaSource("alternate") 32 | 33 | @JvmField 34 | val ByPassTray = MediaSource("by-pass-tray") 35 | 36 | @JvmField 37 | val LargeCapacity = MediaSource("large-capacity") 38 | } 39 | 40 | override fun buildIppAttribute(printerAttributes: IppAttributesGroup) = 41 | IppAttribute("media-source", Keyword, keyword) 42 | 43 | override fun toString() = keyword 44 | 45 | } -------------------------------------------------------------------------------- /printers/Xerox_B210_Printer/007-Get-Job-Attributes.res.txt: -------------------------------------------------------------------------------- 1 | # File: 007-Get-Job-Attributes.res.txt (decoded 688 raw IPP bytes) 2 | version 2.0 3 | successful-ok 4 | request-id 7 5 | operation-attributes-tag 6 | attributes-charset (charset) = utf-8 7 | attributes-natural-language (naturalLanguage) = en-us 8 | printer-uri (uri) = ipp://xero.local 9 | job-attributes-tag 10 | copies (integer) = 1 11 | number-up (integer) = 1 12 | print-quality (enum) = draft 13 | job-uri (uri) = ipp://xero.local/Job-3679 14 | job-id (integer) = 3679 15 | job-name (nameWithoutLanguage) = blank_A4.pdf 16 | job-originating-user-name (nameWithoutLanguage) = ipp-inspector 17 | job-state (enum) = canceled 18 | job-uuid (uri) = urn:uuid:16a65700-007c-1000-bb49-e5f34eac2f55 19 | job-state-reasons (1setOf keyword) = job-canceled-by-user 20 | time-at-completed (integer) = 3632169 (1970-02-12T00:56:09Z) 21 | time-at-creation (integer) = 3632169 (1970-02-12T00:56:09Z) 22 | time-at-processing (integer) = 0 (1970-01-01T00:00Z) 23 | job-k-octets (integer) = 0 24 | job-impressions (integer) = 0 25 | job-k-octets-processed (integer) = 0 26 | job-impressions-completed (integer) = 0 27 | job-media-sheets-completed (integer) = 0 28 | job-printer-uri (uri) = ipp://xero.local 29 | job-printer-up-time (integer) = 3632158 (PT1008H55M58S) 30 | -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/client/HttpPostException.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.client 2 | 3 | /** 4 | * Copyright (c) 2020-2024 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppRequest 8 | import de.gmuth.ipp.core.IppStatus 9 | import java.io.InputStream 10 | import java.util.logging.Level 11 | import java.util.logging.Logger 12 | 13 | class HttpPostException( 14 | request: IppRequest, 15 | val httpStatus: Int? = null, 16 | val httpHeaderFields: Map>? = null, 17 | val httpStream: InputStream? = null, 18 | message: String = with(request) { "http post for request $operation to $printerOrJobUri failed" }, 19 | cause: Throwable? = null 20 | ) : IppExchangeException(request, message, cause) { 21 | 22 | override fun log(logger: Logger, level: Level): Unit = with(logger) { 23 | super.log(logger, level) 24 | httpStatus?.let { log(level) { "HTTP-Status: $it" } } 25 | httpHeaderFields?.let { for ((key: String?, value) in it) log(level) { "HTTP-Header: $key = $value" } } 26 | httpStream?.let { log(level) { "HTTP-Content:\n" + it.bufferedReader().use { it.readText() } } } 27 | cause?.let { log(level) { "Cause: ${it.message}" } } 28 | } 29 | 30 | fun toIppOperationException(status: IppStatus) = 31 | IppOperationException(request, status, message ?: "", cause) 32 | 33 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/ipp/client/IppClientTests.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.client 2 | 3 | /** 4 | * Copyright (c) 2023-2024 Gerhard Muth 5 | */ 6 | 7 | import org.junit.Test 8 | import java.net.URI 9 | import kotlin.test.assertEquals 10 | 11 | class IppClientTests { 12 | val ippClient = IppClient() 13 | 14 | @Test 15 | fun toHttpUriWithEncodedSpace_v1() { 16 | val ippUri = URI.create("ipp://0/PDF%20Printer") 17 | val httpUri = with(ippUri) { 18 | val scheme = scheme.replace("ipp", "http") 19 | val port = if (port == -1) 631 else port 20 | URI.create("$scheme://$host:$port$rawPath") 21 | } 22 | httpUri.run { 23 | assertEquals("http://0:631/PDF%20Printer", toString()) 24 | assertEquals("/PDF%20Printer", rawPath) 25 | assertEquals("/PDF Printer", path) 26 | } 27 | } 28 | 29 | @Test 30 | fun toHttpUriWithEncodedSpace_v2() { 31 | val ippUri = URI.create("ipp://0/PDF%20Printer") 32 | val httpUri = with(ippUri) { 33 | URI(scheme.replace("ipp", "http"), userInfo, host, if(port == -1) 631 else port, path, query, fragment) 34 | } 35 | httpUri.run { 36 | assertEquals("http://0:631/PDF%20Printer", toString()) 37 | assertEquals("/PDF%20Printer", rawPath) 38 | assertEquals("/PDF Printer", path) 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/attributes/DocumentFormat.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.attributes 2 | 3 | /** 4 | * Copyright (c) 2020-2023 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppAttribute 8 | import de.gmuth.ipp.core.IppAttributeBuilder 9 | import de.gmuth.ipp.core.IppAttributesGroup 10 | import de.gmuth.ipp.core.IppTag.MimeMediaType 11 | 12 | class DocumentFormat(val mediaMimeType: String) : IppAttributeBuilder { 13 | 14 | companion object { 15 | // application 16 | 17 | @JvmField 18 | val OCTET_STREAM = DocumentFormat("application/octet-stream") 19 | 20 | @JvmField 21 | val POSTSCRIPT = DocumentFormat("application/postscript") 22 | 23 | @JvmField 24 | val PDF = DocumentFormat("application/pdf") 25 | 26 | // application/vnd 27 | 28 | @JvmField 29 | val HP_PCL = DocumentFormat("application/vnd.hp-PCL") 30 | 31 | // image 32 | 33 | @JvmField 34 | val PWG_RASTER = DocumentFormat("image/pwg-raster") 35 | 36 | @JvmField 37 | val TIFF = DocumentFormat("image/tiff") 38 | 39 | @JvmField 40 | val JPEG = DocumentFormat("image/jpeg") 41 | 42 | @JvmField 43 | val PNG = DocumentFormat("image/png") 44 | 45 | } 46 | 47 | override fun buildIppAttribute(printerAttributes: IppAttributesGroup) = 48 | IppAttribute("document-format", MimeMediaType, mediaMimeType) 49 | 50 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/ipp/core/IppCollectionTests.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.core 2 | 3 | /** 4 | * Copyright (c) 2020-2023 Gerhard Muth 5 | */ 6 | 7 | import java.util.NoSuchElementException 8 | import java.util.logging.Logger.getLogger 9 | import kotlin.test.Test 10 | import kotlin.test.assertEquals 11 | import kotlin.test.assertFailsWith 12 | 13 | class IppCollectionTests { 14 | 15 | private val logger = getLogger(javaClass.name) 16 | private val collection = IppCollection(IppAttribute("foo", IppTag.Keyword, "a", "b")) 17 | 18 | @Test 19 | fun toStringValue() { 20 | assertEquals("{foo=a,b}", collection.toString()) 21 | } 22 | 23 | @Test 24 | fun addAttribute() { 25 | collection.addAttribute("year", IppTag.Integer, 2021) 26 | assertEquals(2, collection.members.size) 27 | } 28 | 29 | @Test 30 | fun getMember() { 31 | val fooAttribute = collection.getMember>("foo") 32 | assertEquals(2, fooAttribute.values.size) 33 | } 34 | 35 | @Test 36 | fun getMemberFails() { 37 | assertFailsWith { 38 | collection.getMember("does-not-exist") 39 | } 40 | } 41 | 42 | @Test 43 | fun logNarrow() { 44 | collection.log(logger) 45 | } 46 | 47 | @Test 48 | fun logWide() { 49 | collection.addAll(listOf(IppAttribute("bar", IppTag.Keyword, "c".repeat(160)))) 50 | collection.log(logger) 51 | } 52 | 53 | } -------------------------------------------------------------------------------- /.github/workflows/analyse.yml: -------------------------------------------------------------------------------- 1 | name: sonarCloud 2 | 3 | on: 4 | workflow_dispatch: 5 | # push: 6 | # branches: 7 | # - master 8 | # - develop 9 | # pull_request: 10 | # branches: [ master ] 11 | 12 | jobs: 13 | sonarCloud: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout sources 17 | uses: actions/checkout@v2 18 | with: 19 | # Disabling shallow clone is recommended for improving relevancy of reporting 20 | fetch-depth: 0 21 | - name: Set up JDK 11 22 | uses: actions/setup-java@v1 23 | with: 24 | java-version: 11 25 | - name: Grant execute permission to gradlew 26 | run: chmod +x gradlew 27 | - name: Analyse with SonarQube 28 | run: ./gradlew test jacocoTestReport sonarqube 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 32 | 33 | # https://github.com/SonarSource/sonarcloud-github-action 34 | # Do not use this GitHub action if you are in the following situations 35 | # Your code is built with Gradle: use the SonarQube plugin for Gradle during the build 36 | # - name: Analyze sources 37 | # uses: sonarsource/sonarcloud-github-action@master 38 | # with: 39 | # args: > 40 | # -Dsonar.organization=gmuth 41 | # -Dsonar.projectKey=gmuth_ipp-client-kotlin 42 | # env: 43 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | # SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 45 | -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/attributes/ColorMode.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.attributes 2 | 3 | /** 4 | * Copyright (c) 2020-2023 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppAttribute 8 | import de.gmuth.ipp.core.IppAttributeBuilder 9 | import de.gmuth.ipp.core.IppAttributesGroup 10 | import de.gmuth.ipp.core.IppException 11 | import de.gmuth.ipp.core.IppTag.Keyword 12 | 13 | // https://ftp.pwg.org/pub/pwg/candidates/cs-ippjobprinterext3v10-20120727-5100.13.pdf - 5.2.3 14 | class ColorMode(private val keyword: String) : IppAttributeBuilder { 15 | 16 | companion object { 17 | @JvmField 18 | val Auto = ColorMode("auto") 19 | 20 | @JvmField 21 | val Color = ColorMode("color") 22 | 23 | @JvmField 24 | val Monochrome = ColorMode("monochrome") 25 | } 26 | 27 | override fun buildIppAttribute(printerAttributes: IppAttributesGroup) = IppAttribute( 28 | when { // use job-creation-attributes-supported? // 5100.11 29 | printerAttributes.containsKey("print-color-mode-supported") -> "print-color-mode" // 5100.14 IPP Everywhere 30 | printerAttributes.containsKey("output-mode-supported") -> "output-mode" // CUPS Extension 31 | else -> throw IppException( 32 | if (printerAttributes.isEmpty()) "Printer attributes required to choose correct attribute" 33 | else "Required attribute not found (print-color-mode-supported or output-mode-supported)" 34 | ) 35 | }, 36 | Keyword, keyword 37 | ) 38 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/ipp/client/IppClientMock.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.client 2 | 3 | /** 4 | * Copyright (c) 2023 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppRequest 8 | import de.gmuth.ipp.core.IppResponse 9 | import de.gmuth.ipp.core.IppStatus 10 | import de.gmuth.log.Logging 11 | import java.io.ByteArrayOutputStream 12 | import java.io.File 13 | import java.net.URI 14 | 15 | class IppClientMock(var directory: String = "printers") : IppClient() { 16 | 17 | init { 18 | Logging.configure() 19 | mockResponse(IppResponse(IppStatus.SuccessfulOk)) 20 | } 21 | 22 | lateinit var rawResponse: ByteArray 23 | 24 | fun mockResponse(response: IppResponse) { 25 | rawResponse = response.encode() 26 | } 27 | 28 | fun mockResponse(file: File) { 29 | rawResponse = file.readBytes() 30 | } 31 | 32 | fun mockResponse(fileName: String, directory: String = this.directory) { 33 | mockResponse(File(directory, fileName)) 34 | } 35 | 36 | // when used with real http, responses are frequently created and garbage collected 37 | // however references to attribute groups are kept in IPP objects 38 | // changes to an attribute group would affect other tests as well 39 | // therefor it's important to produce a fresh response for each mocked call 40 | 41 | override fun httpPost(httpUri: URI, request: IppRequest) = IppResponse().apply { 42 | ByteArrayOutputStream() 43 | .also { request.write(it) } 44 | .toByteArray() 45 | .run { logger.info { "post $size request bytes to $httpUri, ${rawResponse.size} response bytes" } } 46 | decode(rawResponse) 47 | } 48 | } -------------------------------------------------------------------------------- /printers/CUPS_HP_LaserJet_100_color_MFP_M175/Get-Job-Attributes.txt: -------------------------------------------------------------------------------- 1 | number-of-documents (integer) = 1 2 | job-media-progress (integer) = 0 3 | job-more-info (uri) = http://localhost:631/jobs/2366 4 | job-preserved (boolean) = true 5 | job-printer-up-time (integer) = 1627225834 6 | job-printer-uri (uri) = ipp://localhost:631/printers/ColorJet_HP 7 | job-uri (uri) = ipp://localhost:631/jobs/2366 8 | printer-uri (uri) = ipp://localhost/printers/ColorJet_HP 9 | job-originating-user-name (nameWithoutLanguage) = gmuth 10 | job-name (nameWithoutLanguage) = A4-blank.pdf 11 | document-format-detected (mimeMediaType) = application/pdf 12 | document-format (mimeMediaType) = application/pdf 13 | job-priority (integer) = 50 14 | job-uuid (uri) = urn:uuid:c562c176-906a-35db-4c5f-655be623a67e 15 | job-originating-host-name (nameWithoutLanguage) = localhost 16 | date-time-at-completed (dateTime) = 2021-07-25T15:10:31.0+00:00 17 | date-time-at-creation (dateTime) = 2021-07-25T15:10:31.0+00:00 18 | date-time-at-processing (dateTime) = 2021-07-25T15:10:31.0+00:00 19 | time-at-completed (integer) = 1627225831 20 | time-at-creation (integer) = 1627225831 21 | time-at-processing (integer) = 1627225831 22 | job-id (integer) = 2366 23 | job-state (enum) = canceled 24 | job-state-reasons (1setOf keyword) = job-canceled-by-user 25 | job-impressions-completed (integer) = 0 26 | job-media-sheets-completed (integer) = 0 27 | job-k-octets (integer) = 2 28 | job-hold-until (keyword) = no-hold 29 | job-sheets (1setOf nameWithoutLanguage) = none,none 30 | job-printer-state-message (textWithoutLanguage) = 31 | job-printer-state-reasons (1setOf keyword) = cups-ipp-missing-send-document,toner-low-warning,cups-ipp-conformance-failure-report 32 | -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/attributes/JobState.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.attributes 2 | 3 | /** 4 | * Copyright (c) 2020-2023 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppAttribute 8 | import de.gmuth.ipp.core.IppAttributeBuilder 9 | import de.gmuth.ipp.core.IppAttributesGroup 10 | import de.gmuth.ipp.core.IppTag 11 | 12 | // "job-state": type1 enum [RFC8011] 13 | // +----> canceled 14 | // / 15 | // +----> pending --------> processing ---------+------> completed 16 | // | ^ ^ \ 17 | // --->+ | | +----> aborted 18 | // | v v / 19 | // +----> pending-held processing-stopped ---+ 20 | 21 | enum class JobState(val code: Int, private val registeredName: String) : IppAttributeBuilder { 22 | 23 | Pending(3, "pending"), 24 | PendingHeld(4, "pending-held"), 25 | Processing(5, "processing"), 26 | ProcessingStopped(6, "processing-stopped"), 27 | Canceled(7, "canceled"), 28 | Aborted(8, "aborted"), 29 | Completed(9, "completed"); 30 | 31 | // https://www.iana.org/assignments/ipp-registrations/ipp-registrations.xml#ipp-registrations-6 32 | override fun toString() = registeredName 33 | 34 | override fun buildIppAttribute(printerAttributes: IppAttributesGroup) = 35 | IppAttribute("job-state", IppTag.Enum, code) 36 | 37 | companion object { 38 | private fun fromInt(code: Int) = values().single { it.code == code } 39 | fun fromAttributes(attributes: IppAttributesGroup) = fromInt(attributes.getValue("job-state")) 40 | } 41 | 42 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/attributes/Compression.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.attributes 2 | 3 | /** 4 | * Copyright (c) 2024 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppAttribute 8 | import de.gmuth.ipp.core.IppAttributeBuilder 9 | import de.gmuth.ipp.core.IppAttributesGroup 10 | import de.gmuth.ipp.core.IppTag.Keyword 11 | import java.io.InputStream 12 | import java.io.OutputStream 13 | import java.util.zip.DeflaterInputStream 14 | import java.util.zip.DeflaterOutputStream 15 | import java.util.zip.GZIPInputStream 16 | import java.util.zip.GZIPOutputStream 17 | 18 | enum class Compression(val keyword: String) : IppAttributeBuilder { 19 | 20 | COMPRESS("compress"), // RFC 1977 21 | DEFLATE("deflate"), // RFC 1951 22 | GZIP("gzip"), // RFC 1952 23 | NONE("none"); 24 | 25 | override fun buildIppAttribute(printerAttributes: IppAttributesGroup) = 26 | IppAttribute("compression", Keyword, keyword) 27 | 28 | companion object { 29 | fun fromString(string: String) = 30 | Compression.values().single { it.keyword == string } 31 | } 32 | 33 | fun getCompressingOutputStream(outputStream: OutputStream) = when (this) { 34 | NONE -> outputStream 35 | GZIP -> GZIPOutputStream(outputStream) 36 | DEFLATE -> DeflaterOutputStream(outputStream) 37 | else -> throw NotImplementedError("compression '$this'") 38 | } 39 | 40 | fun getDecompressingInputStream(inputStream: InputStream) = when (this) { 41 | NONE -> inputStream 42 | GZIP -> GZIPInputStream(inputStream) 43 | DEFLATE -> DeflaterInputStream(inputStream) 44 | else -> throw NotImplementedError("compression '$this'") 45 | // Apache ZCompressorInputStream? 46 | } 47 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/attributes/MediaMargin.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.attributes 2 | 3 | /** 4 | * Copyright (c) 2020-2023 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppAttribute 8 | import de.gmuth.ipp.core.IppCollection 9 | import de.gmuth.ipp.core.IppTag.Integer 10 | 11 | data class MediaMargin( 12 | var left: Int? = null, 13 | var right: Int? = null, 14 | var top: Int? = null, 15 | var bottom: Int? = null 16 | ) { 17 | constructor(margin: Int) : this(margin, margin, margin, margin) 18 | 19 | fun buildIppAttributes(): Collection> = 20 | ArrayList>().apply { 21 | fun addMargin(side: String, value: Int) = add(IppAttribute("media-$side-margin", Integer, value)) 22 | top?.let { addMargin("top", it) } 23 | left?.let { addMargin("left", it) } 24 | right?.let { addMargin("right", it) } 25 | bottom?.let { addMargin("bottom", it) } 26 | } 27 | 28 | override fun toString() = 29 | if (listOf(top, bottom, left, right).distinct().size == 1) "$top" 30 | else "top=%d;bottom=%d;left=%d;right=%d".format(top, bottom, left, right) 31 | 32 | companion object { 33 | fun fromIppCollection(ippCollection: IppCollection) = ippCollection.run { 34 | MediaMargin().apply { 35 | if (containsMember("media-top-margin")) top = getValue("media-top-margin") 36 | if (containsMember("media-left-margin")) left = getValue("media-left-margin") 37 | if (containsMember("media-right-margin")) right = getValue("media-right-margin") 38 | if (containsMember("media-bottom-margin")) bottom = getValue("media-bottom-margin") 39 | } 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/ipp/client/IppExchangeExceptionTests.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.client 2 | 3 | /** 4 | * Copyright (c) 2020 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppOperation 8 | import de.gmuth.ipp.core.IppRequest 9 | import de.gmuth.ipp.core.IppResponse 10 | import de.gmuth.ipp.core.IppStatus 11 | import de.gmuth.log.Logging 12 | import java.net.URI 13 | import java.util.logging.Logger.getLogger 14 | import kotlin.test.Test 15 | import kotlin.test.assertEquals 16 | 17 | class IppExchangeExceptionTests { 18 | 19 | init { 20 | Logging.configure() 21 | } 22 | 23 | private val logger = getLogger(javaClass.name) 24 | 25 | @Test 26 | fun operationException() { 27 | with( 28 | IppOperationException( 29 | IppRequest(IppOperation.GetPrinterAttributes).apply { encode() }, 30 | IppResponse(status = IppStatus.ClientErrorBadRequest) 31 | ) 32 | ) { 33 | log(logger) 34 | assertEquals(11, request.code) 35 | assertEquals("Get-Printer-Attributes failed: 'client-error-bad-request'", message) 36 | } 37 | } 38 | 39 | @Test 40 | fun httpPostException() { 41 | with( 42 | HttpPostException( 43 | IppRequest( 44 | IppOperation.GetPrinterAttributes, 45 | printerUri = URI.create("ipp://foo") 46 | ).apply { encode() }, 47 | 400 48 | ) 49 | ) { 50 | log(logger) 51 | assertEquals(11, request.code) 52 | assertEquals(400, httpStatus) 53 | assertEquals( "http post for request Get-Printer-Attributes to ipp://foo failed", message) 54 | } 55 | } 56 | 57 | } -------------------------------------------------------------------------------- /src/test/resources/logging.properties: -------------------------------------------------------------------------------- 1 | # ------ levels ------ 2 | # -Djava.util.logging.config.file=path 3 | 4 | .level=INFO 5 | de.gmuth.level=INFO 6 | #de.gmuth.ipp.core.level=FINER 7 | #de.gmuth.ipp.client.level=FINE 8 | #de.gmuth.ipp.client.IppClient.level=FINE 9 | #de.gmuth.ipp.client.IppPrinter.level=FINE 10 | #de.gmuth.ipp.client.IppJob.level=FINE 11 | #de.gmuth.ipp.client.IppSubscription.level=ALL 12 | #de.gmuth.ipp.client.CupsClient.level=FINE 13 | #de.gmuth.ipp.core.IppMessage.level=FINE 14 | #sun.net.www.protocol.level=FINE 15 | 16 | # ------- formatters ------- 17 | 18 | # https://docs.oracle.com/javase/7/docs/api/java/util/logging/SimpleFormatter.html 19 | # https://docs.oracle.com/javase/7/docs/api/java/util/Formatter.html#syntax 20 | # %1 date - a Date object representing event time of the log record. 21 | # %2 source - a string representing the caller, if available; otherwise, the logger's name. 22 | # %3 logger - the logger's name. 23 | # %4 level - the log level. 24 | # %5 message - the formatted log message 25 | # %6 thrown 26 | 27 | java.util.logging.SimpleFormatter.format=%1$tT.%1$tL %3$-25s%4$-9s%5$s%6$s%n 28 | de.gmuth.log.ConsoleHandler.formatter=de.gmuth.log.SimpleClassNameFormatter 29 | de.gmuth.log.StdoutHandler.formatter=de.gmuth.log.SimpleClassNameFormatter 30 | de.gmuth.log.SimpleClassNameFormatter.simpleClassName=true 31 | 32 | # ------ handlers ------ 33 | 34 | handlers=de.gmuth.log.StdoutHandler,java.util.logging.FileHandler 35 | #java.util.logging.FileHandler 36 | #java.util.logging.ConsoleHandler 37 | #de.gmuth.log.StdoutHandler 38 | #de.gmuth.log.ConsoleHandler 39 | 40 | de.gmuth.log.StdoutHandler.level=ALL 41 | #java.util.logging.ConsoleHandler.level=ALL 42 | 43 | java.util.logging.FileHandler.level=FINER 44 | java.util.logging.FileHandler.append=false 45 | java.util.logging.FileHandler.pattern=ipp-client.log 46 | java.util.logging.FileHandler.formatter=de.gmuth.log.SimpleClassNameFormatter 47 | -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/client/IppConfig.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.client 2 | 3 | /** 4 | * Copyright (c) 2021-2024 Gerhard Muth 5 | */ 6 | 7 | import java.nio.charset.Charset 8 | import java.time.Duration 9 | import java.util.Base64.getEncoder 10 | import java.util.logging.Level 11 | import java.util.logging.Level.INFO 12 | import java.util.logging.Logger 13 | import javax.net.ssl.SSLContext 14 | import kotlin.text.Charsets.UTF_8 15 | 16 | class IppConfig( 17 | 18 | // IPP config 19 | var userName: String? = System.getProperty("user.name"), 20 | var ippVersion: String = "2.0", 21 | var charset: Charset = UTF_8, 22 | var naturalLanguage: String = "en", 23 | 24 | // HTTP config 25 | var timeout: Duration = Duration.ofSeconds(30), 26 | var userAgent: String? = "ipp-client/3.4", 27 | var password: String? = null, 28 | var sslContext: SSLContext? = null, 29 | // trust any certificate: sslContextForAnyCertificate() 30 | // use individual certificate: sslContext(loadCertificate(FileInputStream("printer.pem"))) 31 | // use truststore: sslContext(loadKeyStore(FileInputStream("printer.jks"), "changeit")) 32 | var verifySSLHostname: Boolean = true 33 | 34 | ) { 35 | fun authorization() = 36 | "Basic " + getEncoder().encodeToString("$userName:$password".toByteArray(UTF_8)) 37 | 38 | fun trustAnyCertificateAndSSLHostname() { 39 | sslContext = SSLHelper.sslContextForAnyCertificate() 40 | verifySSLHostname = false 41 | } 42 | 43 | @JvmOverloads 44 | fun log(logger: Logger, level: Level = INFO) = logger.run { 45 | log(level) { "userName: $userName" } 46 | log(level) { "ippVersion: $ippVersion" } 47 | log(level) { "charset: ${charset.name().lowercase()}" } 48 | log(level) { "naturalLanguage: $naturalLanguage" } 49 | log(level) { "timeout: $timeout" } 50 | log(level) { "userAgent: $userAgent" } 51 | log(level) { "verifySSLHostname: $verifySSLHostname" } 52 | } 53 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/ipp/iana/IppRegistrationSection2Tests.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.iana 2 | 3 | /** 4 | * Copyright (c) 2020-2024 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.attributes.DocumentFormat 8 | import de.gmuth.ipp.attributes.TemplateAttributes.jobName 9 | import de.gmuth.ipp.core.IppAttributeBuilder 10 | import de.gmuth.ipp.core.IppAttributesGroup 11 | import de.gmuth.ipp.core.IppTag 12 | import de.gmuth.ipp.core.IppTag.Operation 13 | import de.gmuth.ipp.iana.IppRegistrationsSection2.selectGroupForAttribute 14 | import de.gmuth.log.Logging 15 | import java.util.logging.Logger 16 | import kotlin.test.Test 17 | import kotlin.test.assertEquals 18 | 19 | class IppRegistrationSection2Tests { 20 | 21 | init { 22 | Logging.configure() 23 | } 24 | 25 | val logger = Logger.getLogger("IanaRegistrationSec2") 26 | private fun CharSequence.matchesNot(regexString: String) = !matches(regexString.toRegex()) 27 | 28 | @Test 29 | fun listOperationAttributes() { 30 | IppRegistrationsSection2 31 | .attributesMap 32 | .values 33 | .filter { it.collection == "Operation" } 34 | .map { it.name } 35 | .filter { it.matchesNot(".*(deprecated|obsolete|extension).*") } 36 | .distinct() 37 | .sorted() 38 | .onEach { println("\"$it\",") } 39 | //.reduce { list, element -> "$list,$element" } 40 | .forEach { 41 | assertEquals(Operation, selectGroupForAttribute(it), "operation group required for $it") 42 | } 43 | } 44 | 45 | @Test 46 | fun operationGroupAttributes() { 47 | assertEquals(Operation, selectGroupForAttribute(jobName("myjob"))) 48 | assertEquals(Operation, selectGroupForAttribute(DocumentFormat("pdf"))) 49 | } 50 | 51 | fun selectGroupForAttribute(attributeBuilder: IppAttributeBuilder) = 52 | selectGroupForAttribute(attributeBuilder.buildIppAttribute(IppAttributesGroup(IppTag.Printer)).name) 53 | 54 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/attributes/Finishing.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.attributes 2 | 3 | /** 4 | * Copyright (c) 2021-2023 Gerhard Muth 5 | */ 6 | 7 | enum class Finishing(val code: Int) { 8 | None(3), 9 | Staple(4), 10 | Punch(5), 11 | Cover(6), 12 | Bind(7), 13 | SaddleStich(8), 14 | EdgeStich(9), 15 | Fold(10), 16 | Trim(11), 17 | Bale(12), 18 | BookletMaker(13), 19 | JogOffset(14), 20 | Coat(15), 21 | Laminate(16), 22 | StapleTopLeft(20), 23 | StapleBottomLeft(21), 24 | StapleTopRight(22), 25 | StapleBottomRight(23), 26 | EdgeStitchLeft(24), 27 | EdgeStitchTop(25), 28 | EdgeStitchRight(26), 29 | EdgeStitchBottom(27), 30 | StapleDualLeft(28), 31 | StapleDualTop(29), 32 | StapleDualRight(30), 33 | StapleDualBottom(31), 34 | StapleTripleLeft(32), 35 | StapleTripleTop(33), 36 | StapleTripleRight(34), 37 | StapleTripleBottom(35), 38 | BindLeft(50), 39 | BindTop(51), 40 | BindRight(52), 41 | BindBottom(53), 42 | TrimAfterPages(60), 43 | TrimAfterDocuments(61), 44 | TrimAfterCopies(62), 45 | TrimAfterJob(63), 46 | PunchTopLeft(70), 47 | PunchBottomLeft(71), 48 | PunchTopRight(72), 49 | PunchBottomRight(73), 50 | PunchDualLeft(74), 51 | PunchDualTop(75), 52 | PunchDualRight(76), 53 | PunchDualBottom(77), 54 | PunchTripleLeft(78), 55 | PunchTripleTop(79), 56 | PunchTripleRight(80), 57 | PunchTripleBottom(81), 58 | PunchQuadLeft(82), 59 | PunchQuadTop(83), 60 | PunchQuadRight(84), 61 | PunchQuadBottom(85), 62 | PunchMultipleLeft(86), 63 | PunchMultipleTop(87), 64 | PunchMultipleRight(88), 65 | PunchMultipleBottom(89), 66 | FoldAccordion(90), 67 | FoldDoubleGate(91), 68 | FoldGate(92), 69 | FoldHalf(93), 70 | FoldHalfZ(94), 71 | FoldLeftGate(95), 72 | FoldLetter(96), 73 | FoldParallel(97), 74 | FoldPoster(98), 75 | FoldRightGate(99), 76 | FoldZ(100), 77 | FoldEngineeringZ(101) 78 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/core/IppCollection.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.core 2 | 3 | /** 4 | * Copyright (c) 2020-2024 Gerhard Muth 5 | */ 6 | 7 | import java.util.logging.Level 8 | import java.util.logging.Level.INFO 9 | import java.util.logging.Logger 10 | 11 | // RFC8010 3.1.6. 12 | data class IppCollection(val members: MutableCollection> = mutableListOf()) { 13 | 14 | constructor(vararg attributes: IppAttribute<*>) : this(attributes.toMutableList()) 15 | 16 | fun addAttribute(name: String, tag: IppTag, vararg values: Any) = 17 | add(IppAttribute(name, tag, values.toMutableList())) 18 | 19 | fun add(attribute: IppAttribute<*>) = 20 | members.add(attribute) 21 | 22 | fun addAll(attributes: Collection>) = 23 | members.addAll(attributes) 24 | 25 | fun containsMember(memberName: String) = 26 | members.map { it.name }.contains(memberName) 27 | 28 | @Suppress("UNCHECKED_CAST") 29 | fun getMember(memberName: String) = 30 | members.single { it.name == memberName } as IppAttribute 31 | 32 | @Suppress("UNCHECKED_CAST") 33 | fun getMemberOrNull(memberName: String) = 34 | members.singleOrNull { it.name == memberName } as IppAttribute? 35 | 36 | fun getValues(memberName: String) = 37 | getMember(memberName).values 38 | 39 | fun getValue(memberName: String) = 40 | getMember(memberName).value 41 | 42 | fun getValueOrNull(memberName: String) = 43 | getMemberOrNull(memberName)?.value 44 | 45 | val size: Int 46 | get() = members.size 47 | 48 | override fun toString() = members.joinToString(" ", "{", "}") { 49 | "${it.name}=${it.valuesToString()}" 50 | } 51 | 52 | fun log(logger: Logger, level: Level = INFO, prefix: String = "") { 53 | val string = toString() 54 | if (string.length < 160) logger.log(level) { "$prefix$string" } 55 | else members.forEach { member -> member.log(logger, level, prefix) } 56 | } 57 | } -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Gradle 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle 3 | # https://docs.github.com/en/free-pro-team@latest/actions/reference/environment-variables 4 | 5 | name: build 6 | 7 | on: 8 | workflow_dispatch: 9 | push: 10 | # branches: 11 | # - master 12 | # - develop 13 | pull_request: 14 | branches: [ master ] 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | steps: 20 | # https://github.com/marketplace/actions/checkout 21 | - name: Checkout sources 22 | uses: actions/checkout@v5 23 | with: 24 | # Disabling shallow clone is recommended for improving relevancy of reporting 25 | fetch-depth: 0 26 | 27 | # https://github.com/marketplace/actions/setup-java-jdk 28 | - name: Setup JDK 17 29 | uses: actions/setup-java@v5 30 | with: 31 | distribution: 'temurin' 32 | java-version: '17' 33 | #cache: 'gradle' 34 | 35 | #- name: Show Gradle Version 36 | # run: gradle --version 37 | # 9.x 38 | 39 | # https://github.com/marketplace/actions/build-with-gradle 40 | - name: Setup Gradle 7 41 | uses: gradle/actions/setup-gradle@v4 42 | with: 43 | gradle-version: '7.6.6' 44 | cache-read-only: 'false' 45 | - name: Gradle build and analyse 46 | run: gradle sonar 47 | 48 | # - name: Publish GitHub Packages 49 | # env: 50 | # GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 51 | # run: ./gradlew publish 52 | 53 | # - name: Analyse with Sonar 54 | # run: ./gradlew test jacocoTestReport sonar 55 | # run: ./gradlew jacocoTestReport sonar 56 | # uses: gradle/gradle-build-action@v2 57 | # with: 58 | # gradle-version: 7.6.2 59 | # arguments: jacocoTestReport sonar 60 | 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/client/SSLHelper.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.client 2 | 3 | /** 4 | * Copyright (c) 2020-2023 Gerhard Muth 5 | */ 6 | 7 | import java.io.InputStream 8 | import java.security.KeyStore 9 | import java.security.SecureRandom 10 | import java.security.cert.Certificate 11 | import java.security.cert.CertificateFactory 12 | import java.security.cert.X509Certificate 13 | import javax.net.ssl.SSLContext 14 | import javax.net.ssl.TrustManager 15 | import javax.net.ssl.TrustManagerFactory 16 | import javax.net.ssl.X509TrustManager 17 | 18 | object SSLHelper { 19 | 20 | fun loadCertificate(inputStream: InputStream, type: String = "X.509") = 21 | CertificateFactory.getInstance(type).generateCertificate(inputStream) 22 | 23 | fun loadKeyStore(inputStream: InputStream, password: String, type: String = KeyStore.getDefaultType()) = 24 | KeyStore.getInstance(type).apply { load(inputStream, password.toCharArray()) } 25 | 26 | // to support old algorithms like SSLv3, change value of jdk.tls.disabledAlgorithms in java.security 27 | fun sslContext(trustmanagers: Array, protocol: String = "TLS") = 28 | SSLContext.getInstance(protocol).apply { init(null, trustmanagers, SecureRandom()) } 29 | 30 | @SuppressWarnings("kotlin:S4830") 31 | fun sslContextForAnyCertificate() = sslContext(arrayOf( 32 | object : X509TrustManager { 33 | override fun checkClientTrusted(certificates: Array?, string: String?) = Unit 34 | override fun checkServerTrusted(certificates: Array?, string: String?) = Unit 35 | override fun getAcceptedIssuers(): Array = arrayOf() 36 | } 37 | )) 38 | 39 | fun sslContext(keyStore: KeyStore, algorithm: String = TrustManagerFactory.getDefaultAlgorithm()) = 40 | sslContext(TrustManagerFactory.getInstance(algorithm).apply { init(keyStore) }.trustManagers) 41 | 42 | fun sslContext(certificate: Certificate) = sslContext( 43 | KeyStore.getInstance(KeyStore.getDefaultType()).apply { 44 | load(null) // initialize keystore 45 | setCertificateEntry("alias", certificate) 46 | } 47 | ) 48 | 49 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/attributes/MediaSize.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.attributes 2 | 3 | /** 4 | * Copyright (c) 2020-2025 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppAttribute 8 | import de.gmuth.ipp.core.IppAttributeBuilder 9 | import de.gmuth.ipp.core.IppAttributesGroup 10 | import de.gmuth.ipp.core.IppCollection 11 | import de.gmuth.ipp.core.IppTag.BegCollection 12 | import de.gmuth.ipp.core.IppTag.Integer 13 | 14 | // Unit: 1/100 mm, e.g. 2540 = 1 inch 15 | data class MediaSize(val xDimension: Int, val yDimension: Int) : IppAttributeBuilder { 16 | 17 | override fun buildIppAttribute(printerAttributes: IppAttributesGroup) = IppAttribute( 18 | "media-size", BegCollection, 19 | IppCollection( 20 | IppAttribute("x-dimension", Integer, xDimension), 21 | IppAttribute("y-dimension", Integer, yDimension) 22 | ) 23 | ) 24 | 25 | override fun toString() = StringBuilder().run { 26 | append("${xDimension}x${yDimension}") 27 | toString() 28 | } 29 | 30 | fun equalsByDimensions(other: MediaSize) = 31 | compareByDimensions.compare(this, other) == 0 32 | 33 | companion object { 34 | fun fromIppCollection(ippCollection: IppCollection) = ippCollection.run { 35 | MediaSize( 36 | getValue("x-dimension"), 37 | getValue("y-dimension") 38 | ) 39 | } 40 | 41 | val compareByDimensions = compareBy(MediaSize::xDimension, MediaSize::yDimension) 42 | 43 | @JvmField 44 | val ISO_A0 = MediaSize(84100, 118900) 45 | 46 | @JvmField 47 | val ISO_A1 = MediaSize(59400, 84100) 48 | 49 | @JvmField 50 | val ISO_A2 = MediaSize(42000, 59400) 51 | 52 | @JvmField 53 | val ISO_A3 = MediaSize(29700, 42000) 54 | 55 | @JvmField 56 | val ISO_A4 = MediaSize(21000, 29700) 57 | 58 | @JvmField 59 | val ISO_A5 = MediaSize(14800, 21000) 60 | 61 | @JvmField 62 | val ISO_A6 = MediaSize(10500, 14800) 63 | 64 | @JvmField 65 | val ISO_A7 = MediaSize(7400, 10500) 66 | 67 | @JvmField 68 | val ISO_A8 = MediaSize(5200, 7400) 69 | 70 | @JvmField 71 | val ISO_A9 = MediaSize(3700, 5200) 72 | } 73 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/attributes/Marker.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.attributes 2 | 3 | /** 4 | * Copyright (c) 2020-2023 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppAttributesGroup 8 | import de.gmuth.ipp.core.IppString 9 | import java.util.logging.Logger.getLogger 10 | 11 | // https://www.cups.org/doc/spec-ipp.html 12 | class Marker( 13 | val type: String, 14 | val name: String, 15 | val level: Int, 16 | val lowLevel: Int, 17 | val highLevel: Int, 18 | val colorCode: String 19 | ) { 20 | val color: Color = Color.fromString(colorCode) 21 | 22 | fun levelPercent() = 100 * level / highLevel 23 | fun levelIsLow() = level < lowLevel 24 | 25 | override fun toString() = "%-10s %3d %% %5s %-12s %-8s %s".format( 26 | color, levelPercent(), if (levelIsLow()) "(low)" else "", type, colorCode, name 27 | ) 28 | 29 | enum class Color(val code: String) { 30 | NONE("NONE"), 31 | CYAN("#00FFFF"), 32 | BLACK("#000000"), 33 | YELLOW("#FFFF00"), 34 | MAGENTA("#FF00FF"), 35 | CYAN_MAGENTA_YELLOW("#00FFFF#FF00FF#FFFF00"), 36 | UNKNOWN("#?"); 37 | 38 | companion object { 39 | private val logger = getLogger(Color::class.java.name) 40 | fun fromString(code: String) = values().find { it.code == code.uppercase() } 41 | ?: UNKNOWN.apply { logger.warning { "Unknown color code: $code" } } 42 | } 43 | } 44 | 45 | companion object { 46 | fun getMarkers(attributes: IppAttributesGroup): Collection = with(attributes) { 47 | val types = getValues>("marker-types") 48 | val names = getValues>("marker-names") 49 | val levels = getValues>("marker-levels") 50 | val lowLevels = getValues>("marker-low-levels") 51 | val highLevels = getValues>("marker-high-levels") 52 | val colors = getValues>("marker-colors") 53 | types.indices.map { 54 | Marker( 55 | types[it], 56 | names[it].text, 57 | levels[it], 58 | lowLevels[it], 59 | highLevels[it], 60 | colors[it].text 61 | ) 62 | } 63 | } 64 | } 65 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/iana/IppRegistrationsSection4.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.iana 2 | 3 | import de.gmuth.ipp.core.IppException 4 | import java.util.logging.Logger.getLogger 5 | 6 | /** 7 | * Copyright (c) 2020-2024 Gerhard Muth 8 | */ 9 | 10 | // https://www.iana.org/assignments/ipp-registrations/ipp-registrations.xhtml#ipp-registrations-4 11 | object IppRegistrationsSection4 { 12 | 13 | data class KeywordAttributeValue( 14 | val attribute: String, 15 | val keywordValue: String, 16 | val syntax: String, 17 | val type: String, 18 | val reference: String 19 | ) { 20 | constructor(columns: List) : this( 21 | attribute = columns[0], 22 | keywordValue = columns[1], 23 | syntax = columns[2], 24 | type = columns[3], 25 | reference = columns[4] 26 | ) 27 | 28 | override fun toString() = "$attribute $keywordValue ($syntax), $type $reference" 29 | } 30 | 31 | // source: https://www.iana.org/assignments/ipp-registrations/ipp-registrations-4.csv 32 | val allKeywordAttributeValuesTable = CSVTable("/ipp-registrations-4.csv", ::KeywordAttributeValue) 33 | val allKeywordAttributeValues = allKeywordAttributeValuesTable.rows 34 | 35 | fun getKeywordAttributeValuesForAttribute(attribute: String) = allKeywordAttributeValues 36 | .filter { it.attribute == attribute } 37 | .apply { if (isEmpty()) throw IppException("Attribute not found: $attribute") } 38 | 39 | fun getKeywordValuesForAttribute(attribute: String) = getKeywordAttributeValuesForAttribute(attribute) 40 | .filterNot { it.keywordValue.isBlank() || it.keywordValue.contains("Any") } 41 | .map { it.keywordValue } 42 | 43 | private val logger = getLogger(javaClass.name) 44 | 45 | fun listAllAttributes() = allKeywordAttributeValues 46 | .map { it.attribute } 47 | .distinct() 48 | .groupBy { it.take(7) } 49 | .forEach { logger.info { it.value.joinToString(", ") } } 50 | 51 | fun listKeywordValuesForAttribute(attribute: String) { 52 | IppRegistrationsSection2.getAttribute(attribute)?.apply { 53 | logger.info { "keyword values for $name ($syntax), $collection, $reference}" } 54 | } 55 | getKeywordValuesForAttribute(attribute) 56 | .groupBy { it.take(3) } 57 | .forEach { logger.info { it.value.joinToString(", ") } } 58 | } 59 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/client/IppOperationException.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.client 2 | 3 | /** 4 | * Copyright (c) 2020-2025 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppRequest 8 | import de.gmuth.ipp.core.IppResponse 9 | import de.gmuth.ipp.core.IppStatus 10 | import de.gmuth.ipp.core.IppStatus.ClientErrorNotFound 11 | import de.gmuth.ipp.core.IppTag.Operation 12 | import java.nio.file.Path 13 | import java.util.logging.Level 14 | import java.util.logging.Logger 15 | 16 | open class IppOperationException( 17 | request: IppRequest, 18 | val response: IppResponse, 19 | message: String = defaultMessage(request, response), 20 | cause: Throwable? = null 21 | ) : IppExchangeException(request, message, cause) { 22 | 23 | constructor( 24 | request: IppRequest, 25 | status: IppStatus, 26 | message: String, 27 | cause: Throwable? = null 28 | ) : this( 29 | request, 30 | IppResponse( 31 | status = status, 32 | requestId = request.requestId ?: 9999, 33 | statusMessageWithoutLanguage = "$message: ${cause?.message}" 34 | ), 35 | message, 36 | cause 37 | ) 38 | 39 | class ClientErrorNotFoundException(request: IppRequest, response: IppResponse) : 40 | IppOperationException(request, response) { 41 | init { 42 | require(response.status == ClientErrorNotFound) 43 | { "IPP response status is not ClientErrorNotFound: ${response.status}" } 44 | } 45 | } 46 | 47 | companion object { 48 | fun defaultMessage(request: IppRequest, response: IppResponse) = StringBuilder().apply { 49 | append("${request.operation} failed") 50 | with(response) { 51 | append(": '$status'") 52 | if (containsGroup(Operation) && operationGroup.containsKey("status-message")) { 53 | append(", $statusMessage") 54 | } 55 | } 56 | }.toString() 57 | } 58 | 59 | fun statusIs(status: IppStatus) = response.status == status 60 | 61 | override fun log(logger: Logger, level: Level) = with(logger) { 62 | super.log(logger, level) // logs message and request 63 | response.log(this, level, prefix = "RESPONSE: ") 64 | } 65 | 66 | override fun saveMessages(directory: Path, fileNameWithoutSuffix: String) { 67 | request.saveBytes(directory.resolve("$fileNameWithoutSuffix.req")) 68 | response.saveBytes(directory.resolve("$fileNameWithoutSuffix.res")) 69 | } 70 | 71 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/ipp/client/IppMediaTests.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.client 2 | 3 | /** 4 | * Copyright (c) 2021-2024 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.attributes.MediaCollection 8 | import de.gmuth.ipp.attributes.MediaMargin 9 | import de.gmuth.ipp.attributes.MediaSize 10 | import de.gmuth.ipp.attributes.MediaSource 11 | import de.gmuth.ipp.core.IppAttributesGroup 12 | import de.gmuth.ipp.core.IppCollection 13 | import de.gmuth.ipp.core.IppResponse 14 | import de.gmuth.ipp.core.IppTag 15 | import de.gmuth.log.Logging 16 | import org.junit.Test 17 | import java.io.File 18 | import java.util.logging.Logger 19 | import kotlin.test.assertEquals 20 | 21 | class IppMediaTests { 22 | 23 | val logger = Logger.getLogger(javaClass.name) 24 | 25 | init { 26 | Logging.configure() 27 | } 28 | 29 | val emptyPrinterAttributes = IppAttributesGroup(IppTag.Printer) 30 | val xeroxB210Attributes = IppResponse() 31 | .apply { read(File("printers/Xerox_B210_Printer/001-Get-Printer-Attributes.res")) } 32 | .printerGroup 33 | 34 | @Test 35 | fun defaultConstructor() { 36 | MediaCollection().buildIppAttribute(emptyPrinterAttributes) 37 | } 38 | 39 | @Test 40 | fun margin() { 41 | MediaMargin() 42 | MediaMargin(0) 43 | } 44 | 45 | @Test 46 | fun sourceNotSupported() { 47 | MediaCollection(source = MediaSource("invalid")).buildIppAttribute(xeroxB210Attributes) 48 | } 49 | 50 | @Test 51 | fun notProvided() { 52 | MediaCollection(source = MediaSource("main")).buildIppAttribute(emptyPrinterAttributes) 53 | } 54 | 55 | @Test 56 | fun mediaColDefault() { 57 | val ippCollection = xeroxB210Attributes.getValue("media-col-default") 58 | with(MediaCollection.fromIppCollection(ippCollection)) { 59 | // media-size={x-dimension=21000 y-dimension=29700} 60 | assertEquals(MediaSize.ISO_A4, size) 61 | // media-type=stationery 62 | assertEquals("stationery", type) 63 | // media-source=tray-1 64 | assertEquals(MediaSource.Tray1, source) 65 | // media-top-margin=440 media-bottom-margin=440 media-left-margin=440 media-right-margin=440 66 | assertEquals(MediaMargin(440), margin) // uses convenience short cut for same margins 67 | } 68 | } 69 | 70 | //@Test 71 | fun listXeroxMediaAttributes() { 72 | xeroxB210Attributes 73 | .values 74 | .filter { it.name.contains("media") } 75 | .forEach { logger.info { "$it" } } 76 | } 77 | 78 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/attributes/MediaSizeSupported.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.attributes 2 | 3 | /** 4 | * Copyright (c) 2020-2024 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppAttributesGroup 8 | import de.gmuth.ipp.core.IppCollection 9 | 10 | /** 11 | * https://ftp.pwg.org/pub/pwg/candidates/cs-ippjobext21-20230210-5100.7.pdf 12 | * 13 | * 6.9.50 media-size-supported (1setOf collection) 14 | * This REQUIRED attribute lists the supported values of the "media-size" member attribute (section 6.3.1.15). 15 | * Unlike the "media-size" member attribute, the "x-dimension" and "y-dimension" member attributes of 16 | * "media-size-supported" have a syntax of "integer(1:MAX) | rangeOflnteger(1:MAX)" to allow for arbitrary 17 | * ranges of sizes for custom and roll-fed media. 18 | * 19 | * Unit: 1/100 mm, e.g. 2540 = 1 inch 20 | */ 21 | 22 | class MediaSizeSupported(val supportedSizes: Collection) { 23 | 24 | companion object { 25 | fun fromAttributes(attributes: IppAttributesGroup) = MediaSizeSupported( 26 | attributes 27 | .getValues>("media-size-supported") 28 | .map { SupportedSize.fromIppCollection(it) } 29 | ) 30 | } 31 | 32 | fun supports(mediaSize: MediaSize) = 33 | supportedSizes.any { it.supports(mediaSize) } 34 | 35 | override fun toString() = supportedSizes.toString() 36 | 37 | data class SupportedSize(val xDimension: Any, val yDimension: Any) { 38 | 39 | companion object { 40 | fun fromIppCollection(ippCollection: IppCollection) = SupportedSize( 41 | ippCollection.getValue("x-dimension"), 42 | ippCollection.getValue("y-dimension") 43 | ) 44 | } 45 | 46 | init { 47 | require(dimensionsAreInt() || dimensionsAreIntRange()) 48 | } 49 | 50 | override fun toString() = "${xDimension}x${yDimension}" 51 | 52 | fun dimensionsAreInt() = // integer(1:MAX) 53 | xDimension is Int && yDimension is Int 54 | 55 | fun dimensionsAreIntRange() = // rangeOfInteger(1:MAX) 56 | xDimension is IntRange && yDimension is IntRange 57 | 58 | fun supports(mediaSize: MediaSize) = 59 | if (dimensionsAreInt()) { 60 | xDimension as Int == mediaSize.xDimension 61 | && yDimension as Int == mediaSize.yDimension 62 | } else { // dimensionsAreIntRange() 63 | (xDimension as IntRange).contains(mediaSize.xDimension) 64 | && (yDimension as IntRange).contains(mediaSize.yDimension) 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/attributes/TemplateAttributes.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.attributes 2 | 3 | /** 4 | * Copyright (c) 2020-2023 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppAttribute 8 | import de.gmuth.ipp.core.IppResolution 9 | import de.gmuth.ipp.core.IppResolution.Unit 10 | import de.gmuth.ipp.core.IppResolution.Unit.DPI 11 | import de.gmuth.ipp.core.IppTag 12 | import de.gmuth.ipp.core.IppTag.* 13 | 14 | /** 15 | * Create usual job attributes 16 | */ 17 | object TemplateAttributes { 18 | 19 | // For operation group 20 | 21 | @JvmStatic 22 | fun jobName(name: String) = 23 | IppAttribute("job-name", NameWithoutLanguage, name) 24 | 25 | // For job group 26 | 27 | @JvmStatic 28 | fun jobPriority(priority: Int) = 29 | IppAttribute("job-priority", Integer, priority) 30 | 31 | @JvmStatic 32 | fun copies(number: Int) = 33 | IppAttribute("copies", Integer, number) 34 | 35 | @JvmStatic 36 | fun numberUp(up: Int) = 37 | IppAttribute("number-up", Integer, up) 38 | 39 | @JvmStatic 40 | fun printerResolution(resolution: Int, unit: Unit = DPI) = 41 | IppAttribute("printer-resolution", Resolution, IppResolution(resolution, unit)) 42 | 43 | @JvmStatic 44 | fun pageRanges(ranges: Collection) = 45 | IppAttribute("page-ranges", RangeOfInteger, ranges) 46 | 47 | @JvmStatic 48 | fun finishings(values: Collection) = 49 | IppAttribute("finishings", IppTag.Enum, values.map { it.code }) 50 | 51 | @JvmStatic 52 | fun orientationRequested(orientation: Orientation) = 53 | IppAttribute("orientation-requested", IppTag.Enum, orientation.code) 54 | 55 | @JvmStatic 56 | fun outputBin(keyword: String) = // PWG 5100.2 57 | IppAttribute("output-bin", Keyword, keyword) 58 | 59 | @JvmStatic 60 | fun mediaSource(keyword: String) = 61 | IppAttribute("media-source", Keyword, keyword) 62 | 63 | @JvmStatic // input tray 64 | fun mediaColWithSource(mediaSource: MediaSource) = 65 | MediaCollection(source = mediaSource) 66 | 67 | @JvmStatic // input tray 68 | fun mediaColWithSource(keyword: String) = 69 | mediaColWithSource(MediaSource(keyword)) 70 | 71 | @JvmStatic // unit: hundreds of mm 72 | fun mediaColWithSize(xDimension: Int, yDimension: Int) = 73 | MediaCollection(size = MediaSize(xDimension, yDimension)) 74 | 75 | // support vararg parameter for convenience 76 | 77 | @JvmStatic 78 | fun pageRanges(vararg ranges: IntRange) = pageRanges(ranges.toList()) 79 | 80 | @JvmStatic 81 | fun finishings(vararg finishings: Finishing) = finishings(finishings.toList()) 82 | 83 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/ipp/client/issueNo3.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.client 2 | 3 | import java.net.HttpURLConnection 4 | import java.net.URI 5 | import java.util.logging.Level.SEVERE 6 | import java.util.logging.Logger.getLogger 7 | 8 | fun main() { 9 | 10 | val printerUri = URI.create("ipp://xero.local:631/ipp/print") 11 | 12 | //ConsoleLogger.defaultLogLevel = Logging.LogLevel.DEBUG 13 | //HttpURLConnectionClient.log.logLevel = Logging.LogLevel.TRACE 14 | val logger = getLogger("issueNo3") 15 | var ippPrinter: IppPrinter? = null 16 | 17 | // httpConnect(printerUri) 18 | val saveAttributes = false 19 | 20 | val ippConfig = IppConfig().apply { 21 | ippVersion = "1.1" 22 | log(logger) 23 | } 24 | try { 25 | logger.info { "open ipp connection to $printerUri" } 26 | ippPrinter = IppPrinter(printerUri, ippConfig = ippConfig, getPrinterAttributesOnInit = true) 27 | logger.info { "successfully connected $printerUri" } 28 | } catch (exception: Exception) { 29 | logger.log(SEVERE, exception, { "failed to connect to $printerUri" }) 30 | } 31 | 32 | if (ippPrinter != null) ippPrinter.run { 33 | if (saveAttributes) { 34 | try { 35 | savePrinterAttributes() 36 | logger.info { "saved printer attributes" } 37 | } catch (exception: Exception) { 38 | logger.log(SEVERE, exception, { "failed to save printer attributes" }) 39 | } 40 | } 41 | try { 42 | logger.info { "documentFormatSupported:" } 43 | documentFormatSupported.forEach { logger.info { " $it" } } 44 | } catch (exception: Exception) { 45 | logger.log(SEVERE, exception, { "failed to read documentFormatSupported" }) 46 | } 47 | } 48 | } 49 | 50 | fun httpConnect(printerUri: URI) { 51 | val log = getLogger("httpConnect") 52 | val printerUrl = URI("http://${printerUri.host}:${printerUri.port}").toURL() 53 | try { 54 | log.info { "open http connection to $printerUrl" } 55 | with(printerUrl.openConnection() as HttpURLConnection) { 56 | log.info { "response: $responseCode $responseMessage" } 57 | val contentResponseStream = try { 58 | inputStream 59 | } catch (exception: Exception) { 60 | errorStream 61 | } 62 | val contentBytes = contentResponseStream.readBytes() 63 | log.info { "content: ${contentBytes.size} bytes of type '$contentType'" } 64 | } 65 | } catch (exception: Exception) { 66 | log.log(SEVERE, exception, { "http connection failed to $printerUrl" }) 67 | } 68 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/iana/IppRegistrationsSection6.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.iana 2 | 3 | import java.util.logging.Logger.getLogger 4 | 5 | /** 6 | * Copyright (c) 2020-2023 Gerhard Muth 7 | */ 8 | 9 | // https://www.iana.org/assignments/ipp-registrations/ipp-registrations.xhtml#ipp-registrations-6 10 | object IppRegistrationsSection6 { 11 | 12 | private val logger = getLogger(javaClass.name) 13 | 14 | data class EnumAttributeValue( 15 | val attribute: String, 16 | val value: String, 17 | val name: String, 18 | val syntax: String, 19 | val reference: String 20 | ) { 21 | constructor(columns: List) : this( 22 | attribute = columns[0], 23 | value = columns[1], 24 | name = columns[2], 25 | syntax = columns[3], 26 | reference = columns[4] 27 | ) 28 | 29 | override fun toString() = "$attribute/$value ($syntax) = $name $reference " 30 | } 31 | 32 | internal val enumAttributeValuesMap: Map 33 | internal val aliasMap: Map 34 | 35 | init { 36 | // source: https://www.iana.org/assignments/ipp-registrations/ipp-registrations-6.csv 37 | val allEnumAttributeValues = CSVTable("/ipp-registrations-6.csv", ::EnumAttributeValue).rows 38 | 39 | enumAttributeValuesMap = allEnumAttributeValues.associateBy { "${it.attribute}/${it.value}" } 40 | 41 | // alias example: finishings-default, 42 | aliasMap = mutableMapOf().apply { 43 | allEnumAttributeValues 44 | .filter { it.value.lowercase().contains("any") } 45 | .forEach { put(it.attribute, it.value.replace("^.*\"(.+)\".*$".toRegex(), "$1")) } 46 | 47 | // cups extension 48 | put("landscape-orientation-requested-preferred", "orientation-requested") // auto-rotate 49 | } 50 | } 51 | 52 | fun getEnumAttributeValue(attribute: String, value: Any) = 53 | enumAttributeValuesMap["$attribute/$value"] 54 | 55 | fun getEnumName(attribute: String, value: Any) = 56 | if (attribute == "operations-supported" && value is Number) { 57 | // lookup the name in IppOperation because CUPS operations are not iana registered 58 | de.gmuth.ipp.core.IppOperation.fromInt(value.toInt()).registeredName() 59 | } else { 60 | getEnumAttributeValue(aliasMap[attribute] ?: attribute, value)?.name 61 | } ?: value 62 | 63 | fun listEnumValues(attribute: String) { 64 | enumAttributeValuesMap.values 65 | .filter { it.attribute == attribute } 66 | .forEach { logger.info { it.toString() } } 67 | } 68 | 69 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/ipp/client/CupsClientTests.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.client 2 | 3 | /** 4 | * Copyright (c) 2020-2025 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.attributes.PrinterState 8 | import de.gmuth.ipp.core.IppException 9 | import de.gmuth.ipp.core.IppStatus.ClientErrorNotFound 10 | import org.junit.Test 11 | import java.net.URI 12 | import java.util.logging.Logger.getLogger 13 | import kotlin.test.assertEquals 14 | import kotlin.test.assertFailsWith 15 | import kotlin.test.assertTrue 16 | 17 | class CupsClientTests { 18 | private val logger = getLogger(javaClass.name) 19 | val ippClientMock = IppClientMock("printers/CUPS") 20 | val cupsClient = CupsClient(URI.create("ipps://cups"), ippClient = ippClientMock) 21 | 22 | @Test 23 | fun constructors() { 24 | CupsClient() 25 | CupsClient(URI("ipps://host")) 26 | } 27 | 28 | @Test 29 | fun getPrinters() { 30 | ippClientMock.mockResponse("Cups-Get-Printers.ipp") 31 | cupsClient.getPrinters().run { 32 | forEach { logger.info { it.toString() } } 33 | assertEquals(12, size) 34 | } 35 | } 36 | 37 | @Test 38 | fun getPrinter() { 39 | ippClientMock.mockResponse("Get-Printer-Attributes.ipp", "printers/CUPS_HP_LaserJet_100_color_MFP_M175") 40 | cupsClient.getPrinter("ColorJet_HP").run { 41 | log(logger) 42 | assertEquals("HP LaserJet 100 color MFP M175", makeAndModel.text) 43 | assertEquals(PrinterState.Idle, state) 44 | assertEquals(5, markers.size) 45 | assertTrue(isAcceptingJobs) 46 | assertTrue(isCups()) 47 | assertEquals("2.2.5", cupsVersion) 48 | } 49 | } 50 | 51 | @Test 52 | fun getCupsVersion() { 53 | ippClientMock.mockResponse("Get-Printer-Attributes.ipp", "printers/CUPS_HP_LaserJet_100_color_MFP_M175") 54 | assertEquals("2.2.5", cupsClient.version) 55 | } 56 | 57 | @Test 58 | fun getPrinterFails() { 59 | assertFailsWith { // no such cups printer 60 | cupsClient.getPrinter("invalid") 61 | } 62 | } 63 | 64 | @Test 65 | fun getDefault() { 66 | ippClientMock.mockResponse("Cups-Get-Default.ipp") 67 | cupsClient.getDefault().run { 68 | assertEquals("ColorJet_HP", name.text) 69 | } 70 | } 71 | 72 | @Test 73 | fun getDefaultFails() { 74 | val exception = assertFailsWith { 75 | ippClientMock.mockResponse("Cups-Get-Default-Error.ipp") 76 | cupsClient.getDefault() 77 | } 78 | assertTrue(exception.statusIs(ClientErrorNotFound)) 79 | } 80 | 81 | @Test 82 | fun setDefault() { 83 | cupsClient.setDefault("matrix") 84 | } 85 | 86 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/ipp/core/IppResponseTests.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.core 2 | 3 | /** 4 | * Copyright (c) 2020-2024 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.log.Logging 8 | import java.net.URI 9 | import java.util.logging.Logger.getLogger 10 | import kotlin.test.Test 11 | import kotlin.test.assertEquals 12 | import kotlin.test.assertTrue 13 | 14 | class IppResponseTests { 15 | 16 | init { 17 | Logging.configure() 18 | } 19 | 20 | private val logger = getLogger(javaClass.name) 21 | private val ippResponse = IppResponse() 22 | 23 | @Test 24 | fun printJobResponse() = ippResponse.run { 25 | readTestResource("/printJob.response") 26 | assertTrue(isSuccessful()) 27 | assertEquals(0, printerGroup.size) 28 | assertEquals(0, jobGroup.size) 29 | assertEquals(0, unsupportedGroup.size) 30 | assertEquals("successful-ok", codeDescription) 31 | assertEquals("not-infected", statusMessage.toString()) 32 | } 33 | 34 | @Test 35 | fun setStatus() = ippResponse.run { 36 | status = IppStatus.ClientErrorDocumentFormatNotSupported 37 | assertEquals(0x040A, code) 38 | } 39 | 40 | @Test 41 | fun invalidXeroxMediaColResponse() = ippResponse.run { 42 | readTestResource("/invalidXeroxMediaCol.response") 43 | log(logger) 44 | with(jobGroup) { 45 | assertEquals(598, getValue("job-id")) 46 | assertEquals(4, getValue("job-state")) // pending-held 47 | assertEquals(listOf("job-hold-until-specified"), getValues("job-state-reasons")) 48 | assertEquals(URI.create("ipp://xero.local./ipp/print/Job-598"), getValue("job-uri")) 49 | } 50 | with(unsupportedGroup) { 51 | assertEquals(0, getValue("media-col").size) 52 | } 53 | } 54 | 55 | @Test 56 | fun invalidHpNameWithLanguageResponse() = ippResponse.run { 57 | // IppInputStream solution: first mark(2) then NameWithLanguage -> readShort().let { if (markSupported() && it < 6) reset() } 58 | // requestNaturalLanguage = "de" // triggers HP name with language bug 59 | readTestResource("/invalidHpNameWithLanguage.response") 60 | log(logger) 61 | with(jobGroup) { 62 | assertEquals(IppString("A4-blank.pdf", "de"), getValue("job-name")) 63 | assertEquals(993, getValue("job-id")) 64 | assertEquals(7, getValue("job-state")) // canceled 65 | assertEquals(listOf("none"), getValues("job-state-reasons")) 66 | assertEquals(URI.create("ipp://ColorJet.local/ipp/printer/0993"), getValue("job-uri")) 67 | } 68 | } 69 | 70 | @Test 71 | fun createResponse() { 72 | with(IppResponse(IppStatus.SuccessfulOk)) { 73 | assertTrue(isSuccessful()) 74 | } 75 | } 76 | 77 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/core/IppResponse.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.core 2 | 3 | /** 4 | * Copyright (c) 2020-2024 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppTag.* 8 | import java.nio.charset.Charset 9 | import java.util.logging.Level 10 | import java.util.logging.Logger 11 | 12 | @Suppress("kotlin:S1192") 13 | class IppResponse : IppMessage { 14 | 15 | var httpServer: String? = null 16 | 17 | override val codeDescription: String 18 | get() = status.toString() 19 | 20 | var status: IppStatus 21 | get() = IppStatus.fromInt(code!!) 22 | set(ippStatus) { 23 | code = ippStatus.code 24 | } 25 | 26 | // https://datatracker.ietf.org/doc/html/rfc8011#page-42 27 | val statusMessage: IppString 28 | get() = operationGroup.getValue("status-message") 29 | 30 | val unsupportedGroup: IppAttributesGroup 31 | get() = getSingleAttributesGroup(Unsupported) 32 | 33 | fun isSuccessful() = status.isSuccessful() 34 | 35 | constructor() : super() 36 | 37 | @JvmOverloads 38 | constructor( 39 | status: IppStatus, 40 | version: String = "2.0", 41 | requestId: Int = 1, 42 | charset: Charset = Charsets.UTF_8, 43 | naturalLanguage: String = "en", 44 | statusMessage: IppString? = null, 45 | ) : super(version, requestId, charset, naturalLanguage) { 46 | code = status.code 47 | with(operationGroup) { 48 | statusMessage?.let { attribute("status-message", TextWithLanguage, it) } 49 | } 50 | } 51 | 52 | @JvmOverloads 53 | constructor( 54 | status: IppStatus, 55 | requestId: Int, 56 | statusMessageWithoutLanguage: String, 57 | statusMessageLanguage: String = "en" 58 | ) : this( 59 | status = status, 60 | requestId = requestId, 61 | statusMessage = IppString(statusMessageWithoutLanguage, statusMessageLanguage) 62 | ) 63 | 64 | override fun log(logger: Logger, level: Level, prefix: String) { 65 | httpServer?.let { logger.log(level) { "${prefix}httpServer = $it" } } 66 | super.log(logger, level, prefix) 67 | } 68 | 69 | override fun toString() = StringBuilder().apply { 70 | append(status) 71 | if (!status.isSuccessful() && operationGroup.containsKey("status-message")) { 72 | append(", '${statusMessage.text}'") 73 | } 74 | 75 | val statesAndReasons = attributesGroups 76 | .flatMap { group -> group.values } 77 | .filter { attribute -> Regex(".*-state(-reasons)?").matches(attribute.name) } 78 | .sortedBy { it.name } 79 | .map { it.valuesToString() } 80 | .filter { it.isNotEmpty() && it != "none" } 81 | if (statesAndReasons.isNotEmpty()) 82 | append(statesAndReasons.joinToString(",", " [", "]")) 83 | 84 | val groups = attributesGroups 85 | .filter { group -> group.tag != Operation } 86 | .map { "${it.size} ${it.tag.name.lowercase()} attributes" } 87 | if (groups.isNotEmpty()) 88 | append(groups.joinToString(", ", " (", ")")) 89 | 90 | }.toString() 91 | } -------------------------------------------------------------------------------- /.github/workflows/scorecard_ynl: -------------------------------------------------------------------------------- 1 | # https://github.com/ossf/scorecard-action 2 | 3 | name: Scorecard supply-chain security 4 | on: 5 | # For Branch-Protection check. Only the default branch is supported. See 6 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection 7 | branch_protection_rule: 8 | # To guarantee Maintained check is occasionally updated. See 9 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained 10 | schedule: 11 | - cron: '49 04 * * 3' 12 | push: 13 | branches: [ "master" ] 14 | 15 | # Declare default permissions as read only. 16 | permissions: read-all 17 | 18 | jobs: 19 | analysis: 20 | name: Scorecard analysis 21 | runs-on: ubuntu-latest 22 | permissions: 23 | # Needed to upload the results to code-scanning dashboard. 24 | security-events: write 25 | # Needed to publish results and get a badge (see publish_results below). 26 | id-token: write 27 | # Uncomment the permissions below if installing in a private repository. 28 | # contents: read 29 | # actions: read 30 | 31 | steps: 32 | - name: "Checkout code" 33 | uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 34 | with: 35 | persist-credentials: false 36 | 37 | - name: "Run analysis" 38 | uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1 39 | with: 40 | results_file: results.sarif 41 | results_format: sarif 42 | # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: 43 | # - you want to enable the Branch-Protection check on a *public* repository, or 44 | # - you are installing Scorecard on a *private* repository 45 | # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional. 46 | # repo_token: ${{ secrets.SCORECARD_TOKEN }} 47 | 48 | # Public repositories: 49 | # - Publish results to OpenSSF REST API for easy access by consumers 50 | # - Allows the repository to include the Scorecard badge. 51 | # - See https://github.com/ossf/scorecard-action#publishing-results. 52 | # For private repositories: 53 | # - `publish_results` will always be set to `false`, regardless 54 | # of the value entered here. 55 | publish_results: false 56 | 57 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF 58 | # format to the repository Actions tab. 59 | - name: "Upload artifact" 60 | uses: actions/upload-artifact@97a0fba1372883ab732affbe8f94b823f91727db # v3.pre.node20 61 | with: 62 | name: SARIF file 63 | path: results.sarif 64 | retention-days: 5 65 | 66 | # Upload the results to GitHub's code scanning dashboard (optional). 67 | # Commenting out will disable upload of results to your repo's Code Scanning dashboard 68 | - name: "Upload to code-scanning" 69 | uses: github/codeql-action/upload-sarif@v3 70 | with: 71 | sarif_file: results.sarif 72 | -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/attributes/MediaColDatabase.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.attributes 2 | 3 | /** 4 | * Copyright (c) 2020-2023 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppAttributesGroup 8 | import de.gmuth.ipp.core.IppCollection 9 | import java.util.logging.Level 10 | import java.util.logging.Logger 11 | 12 | /* 13 | * https://ftp.pwg.org/pub/pwg/candidates/cs-ippjobext20-20190816-5100.7.pdf (section 5.5.32) 14 | * 15 | * This attribute lists the set of pre-defined "media-col" collections available in the Printer’s media database. 16 | * This attribute is similar to “media-col-ready” (section 5.5.34) but returns the entire set of pre-defined "media-col" 17 | * collections known by the Printer instead of just the media loaded in the Printer. 18 | */ 19 | 20 | class MediaColDatabase(val mediaCollections: List) { 21 | 22 | companion object { 23 | fun fromAttributes(attributes: IppAttributesGroup) = 24 | fromIppCollections(attributes.getValues("media-col-database")) 25 | 26 | fun fromIppCollections(mediaIppCollections: List) = 27 | MediaColDatabase(mediaIppCollections.map { MediaCollection.fromIppCollection(it) }) 28 | } 29 | 30 | fun findByMediaSize(size: MediaSize) = 31 | mediaCollections.filter { it.sizeEqualsByDimensions(size) } 32 | 33 | fun findByMediaSizeNameContaining(text: String) = 34 | mediaCollections.filter { it.sizeName?.contains(text) ?: false } 35 | 36 | fun findByMediaKeyContaining(text: String) = 37 | mediaCollections.filter { it.key?.contains(text) ?: false } 38 | 39 | val distinctMediaKeys: List 40 | get() = mediaCollections.mapNotNull { it.key }.distinct().toList() 41 | 42 | val distinctMediaTypes: List 43 | get() = mediaCollections.mapNotNull { it.type }.distinct().toList() 44 | 45 | val distinctMediaSources: List 46 | get() = mediaCollections.mapNotNull { it.source }.distinct().toList() 47 | 48 | val distinctMediaSizes: List 49 | get() = mediaCollections.mapNotNull { it.size }.distinct().toList() 50 | 51 | override fun toString() = StringBuilder("MEDIA-COL-DATABASE:").apply { 52 | append(" ${mediaCollections.size} definitions") 53 | append(", ${distinctMediaSources.size} distinct sources") 54 | append(", ${distinctMediaSizes.size} distinct sizes") 55 | append(", ${distinctMediaTypes.size} distinct types") 56 | append(", ${distinctMediaKeys.size} distinct keys") 57 | }.toString() 58 | 59 | @JvmOverloads 60 | fun log(logger: Logger, level: Level = Level.INFO) { 61 | logger.log(level, toString()) 62 | distinctMediaSources.run { 63 | if (isNotEmpty()) logger.log(level, "media-sources:") 64 | forEach { logger.log(level, " $it") } 65 | } 66 | distinctMediaSizes.run { 67 | if (isNotEmpty()) logger.log(level, "media-sizes:") 68 | forEach { logger.log(level, " $it") } 69 | } 70 | distinctMediaTypes.run { 71 | if (isNotEmpty()) logger.log(level, "media-types:") 72 | forEach { logger.log(level, " $it") } 73 | } 74 | distinctMediaKeys.run { 75 | if (isNotEmpty()) logger.log(level, "media-keys:") 76 | forEach { logger.log(level, " $it") } 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/ipp/core/IppRequestTests.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.core 2 | 3 | /** 4 | * Copyright (c) 2020-2023 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppOperation.CreateJobSubscriptions 8 | import java.net.URI 9 | import java.time.Duration 10 | import java.util.logging.Logger.getLogger 11 | import kotlin.test.Test 12 | import kotlin.test.assertEquals 13 | import kotlin.test.assertFailsWith 14 | import kotlin.test.assertNotNull 15 | 16 | class IppRequestTests { 17 | 18 | private val logger = getLogger(javaClass.name) 19 | 20 | @Test 21 | fun requestConstructor1() { 22 | IppRequest().run { 23 | code = 5 24 | logger.info { toString() } 25 | log(logger) 26 | assertEquals(null, version) 27 | assertEquals(IppOperation.CreateJob, operation) 28 | createAttributesGroup(IppTag.Operation) 29 | assertFailsWith { printerOrJobUri } 30 | } 31 | } 32 | 33 | @Test 34 | fun requestConstructor2() { 35 | val request = IppRequest(IppOperation.StartupPrinter, URI.create("ipp://foo")) 36 | assertEquals(1, request.requestId) 37 | assertEquals("2.0", request.version) 38 | assertEquals(IppOperation.StartupPrinter, request.operation) 39 | assertEquals(Charsets.UTF_8, request.attributesCharset) 40 | assertEquals("en", request.operationGroup.getValue("attributes-natural-language")) 41 | assertEquals("ipp://foo", request.printerOrJobUri.toString()) 42 | assertEquals("Startup-Printer", request.codeDescription) 43 | val requestEncoded = request.encode() 44 | assertEquals(97, requestEncoded.size) 45 | } 46 | 47 | @Test 48 | fun printJobRequest() { 49 | val request = IppRequest( 50 | IppOperation.PrintJob, URI.create("ipp://printer"), 51 | listOf("one", "two"), "user" 52 | ) 53 | request.documentInputStream = "pdl-content".byteInputStream() 54 | logger.info { request.toString() } 55 | request.log(logger) 56 | val requestEncoded = request.encode() 57 | logger.info { "encoded ${requestEncoded.size} bytes" } 58 | val requestDecoded = IppRequest() 59 | requestDecoded.run { 60 | decode(requestEncoded) 61 | assertEquals("2.0", version) 62 | assertEquals(IppOperation.PrintJob, operation) 63 | assertEquals(1, requestId) 64 | assertNotNull(operationGroup) 65 | operationGroup.run { 66 | assertEquals(Charsets.UTF_8, getValue("attributes-charset")) 67 | assertEquals("en", getValue("attributes-natural-language")) 68 | assertEquals(URI.create("ipp://printer"), getValue("printer-uri")) 69 | } 70 | assertEquals(listOf("one", "two"), requestedAttributes) 71 | assertEquals("user", requestingUserName) 72 | assertEquals("pdl-content", String(documentInputStream!!.readBytes())) 73 | } 74 | } 75 | 76 | @Test 77 | fun createSubscriptionAttributesGroup() { 78 | IppRequest(CreateJobSubscriptions, URI.create("ipp://foo")) 79 | .createSubscriptionAttributesGroup( 80 | listOf("all"), 81 | Duration.ofHours(1), 82 | Duration.ofMinutes(1), 83 | 999 84 | ) 85 | } 86 | 87 | @Test 88 | fun createSubscriptionAttributesGroupWithNullDefaults() { 89 | IppRequest(CreateJobSubscriptions, URI.create("ipp://null")) 90 | .createSubscriptionAttributesGroup() 91 | } 92 | 93 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/iana/CSVTable.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.iana 2 | 3 | /** 4 | * Copyright (c) 2021-2023 Gerhard Muth 5 | */ 6 | 7 | import java.io.InputStream 8 | 9 | // https://tools.ietf.org/html/rfc4180 10 | 11 | class CSVTable( 12 | inputStream: InputStream? = null, 13 | val buildRow: (columns: List) -> T, 14 | skipHeader: Boolean = true 15 | ) { 16 | 17 | val rows: MutableList = mutableListOf() 18 | val maxLengthMap = mutableMapOf() 19 | 20 | init { 21 | inputStream?.let { read(it.buffered(), skipHeader) } 22 | } 23 | 24 | constructor(resourcePath: String, rowFactory: (columns: List) -> T) : 25 | this(CSVTable::class.java.getResourceAsStream(resourcePath), rowFactory) 26 | 27 | fun updateMaxLengthMap(columnIndex: Int, columnLength: Int) = with(maxLengthMap[columnIndex]) { 28 | if (this == null || this < columnLength) maxLengthMap[columnIndex] = columnLength 29 | } 30 | 31 | fun read(inputStream: InputStream, skipHeader: Boolean) { 32 | if (skipHeader) parseRow(inputStream) 33 | lineLoop@ while (true) { 34 | val rawRow = parseRow(inputStream) ?: break@lineLoop 35 | rows.add(buildRow(rawRow)) 36 | } 37 | } 38 | 39 | fun parseRow(inputStream: InputStream): List? { 40 | val fields = mutableListOf() 41 | val currentField = StringBuilder() 42 | var inQuote = false 43 | var lastCharacterWasQuote = false 44 | fun updateMaxLengthMap() = updateMaxLengthMap(fields.size - 1, fields.last().length) 45 | columnLoop@ while (true) { 46 | val i = inputStream.read() 47 | if (i == -1) break@columnLoop 48 | val char = i.toChar() 49 | var appendCharacter = false 50 | if (inQuote) { 51 | appendCharacter = char != '"' 52 | } else { 53 | when (char) { 54 | ',' -> { 55 | fields.add(currentField.toString()) 56 | updateMaxLengthMap() 57 | currentField.clear() 58 | } 59 | '\n' -> { 60 | fields.add(currentField.toString()) 61 | updateMaxLengthMap() 62 | return fields 63 | } 64 | '"' -> { 65 | appendCharacter = lastCharacterWasQuote 66 | } 67 | else -> { 68 | appendCharacter = char != '\r' 69 | } 70 | } 71 | } 72 | lastCharacterWasQuote = char == '"' 73 | if (lastCharacterWasQuote) inQuote = !inQuote 74 | if (appendCharacter) currentField.append(char) 75 | } 76 | if (currentField.isEmpty()) return null 77 | fields.add(currentField.toString()) 78 | updateMaxLengthMap() 79 | return fields 80 | } 81 | 82 | companion object { 83 | fun print(inputStream: InputStream, delimiter: String = "|") = CSVTable(inputStream, { it }).run { 84 | for (row in rows) with(StringBuffer(delimiter)) { 85 | for ((columnIndex, column) in row.withIndex()) { 86 | append("%-${maxLengthMap[columnIndex]}s%s".format(column, delimiter)) 87 | } 88 | println(toString()) 89 | } 90 | } 91 | 92 | fun print(resourcePath: String, delimiter: String = "|") = 93 | print(CSVTable::class.java.getResourceAsStream(resourcePath)!!, delimiter) 94 | } 95 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/ipp/tool/IppTool.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.tool 2 | 3 | import de.gmuth.ipp.client.IppClient 4 | import de.gmuth.ipp.core.* 5 | import java.io.File 6 | import java.io.FileInputStream 7 | import java.io.InputStream 8 | import java.io.Reader 9 | import java.net.URI 10 | import java.nio.charset.Charset 11 | import java.util.logging.Logger.getLogger 12 | 13 | class IppTool { 14 | val log = getLogger(javaClass.name) 15 | var verbose: Boolean = false 16 | var uri: URI? = null 17 | var filename: String? = null 18 | val request = IppRequest().apply { 19 | version = "1.1" 20 | requestId = 1 21 | } 22 | lateinit var currentGroup: IppAttributesGroup 23 | 24 | fun interpretResource(resource: String) = interpret(javaClass.getResourceAsStream(resource)) 25 | fun interpretFile(path: String) = interpretFile(File(path)) 26 | fun interpretFile(file: File) = interpret(FileInputStream(file)) 27 | fun interpret(inputStream: InputStream) = interpret(inputStream.reader()) 28 | fun interpret(reader: Reader) = interpret(reader.readLines()) 29 | fun interpret(vararg lines: String) = if (lines.size == 1) interpret(lines[0].reader()) else interpret(lines.toList()) 30 | 31 | fun interpret(lines: List) { 32 | for (line: String in lines) { 33 | if (line.startsWith("#")) continue 34 | if (verbose) println("| ${line.trim()}") 35 | val lineItems = line.trim().split("\\s+".toRegex()) 36 | if (lineItems.size > 1) interpretLine(lineItems) 37 | } 38 | executeIppRequest().log(log) 39 | } 40 | 41 | private fun interpretLine(lineItems: List) { 42 | val command = lineItems.first() 43 | val firstArgument = lineItems[1] 44 | when (command) { 45 | "OPERATION" -> { 46 | val operation = IppOperation.values().single { it.registeredName() == firstArgument } 47 | request.code = operation.code 48 | } 49 | "GROUP" -> { 50 | val groupTag = IppTag.fromString(firstArgument) 51 | currentGroup = request.createAttributesGroup(groupTag) 52 | } 53 | "ATTR" -> { 54 | val attribute = interpretAttr(lineItems) 55 | currentGroup.put(attribute) 56 | } 57 | "FILE" -> { 58 | if (firstArgument == "\$filename") { 59 | if (filename == null) throw IppException("$firstArgument undefined") 60 | } else { 61 | filename = firstArgument 62 | } 63 | request.documentInputStream = FileInputStream(File(filename)) 64 | } 65 | else -> println("ignore unknown command '$command'") 66 | } 67 | } 68 | 69 | private fun interpretAttr(lineItems: List): IppAttribute<*> { 70 | val tagName = if (lineItems[1] == "language") "naturalLanguage" else lineItems[1] 71 | val tag = IppTag.fromString(tagName) 72 | val name = lineItems[2] 73 | val valueString = lineItems[3] 74 | val value: Any = when { 75 | valueString == "\$uri" -> uri ?: throw IppException("\$uri undefined") 76 | tag == IppTag.Uri -> URI.create(valueString) 77 | tag == IppTag.Charset -> Charset.forName(valueString) 78 | else -> valueString 79 | } 80 | return IppAttribute(name, tag, value) 81 | } 82 | 83 | private fun executeIppRequest() = with(IppClient()) { 84 | verbose = true 85 | if (uri == null) throw IppException("uri missing") 86 | exchange(request) 87 | } 88 | 89 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/attributes/PrinterType.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.attributes 2 | 3 | /** 4 | * Copyright (c) 2020-2024 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppAttribute 8 | import de.gmuth.ipp.core.IppAttributeBuilder 9 | import de.gmuth.ipp.core.IppAttributesGroup 10 | import de.gmuth.ipp.core.IppTag 11 | import java.util.logging.Level 12 | import java.util.logging.Level.INFO 13 | import java.util.logging.Logger 14 | 15 | // https://www.cups.org/doc/spec-ipp.html 16 | class PrinterType(val value: Int) : IppAttributeBuilder { 17 | 18 | enum class Capability(bitNumber: Int, val description: String) { 19 | IsAPrinterClass(0, "Is a printer class."), 20 | IsARemoteDestination(1, "Is a remote destination."), 21 | CanPrintInBlack(2, "Can print in black."), 22 | CanPrintInColor(3, "Can print in color."), 23 | CanPrintOnBothSidesOfThePageInHardware(4, "Can print on both sides of the page in hardware."), 24 | CanStapleOutput(5, "Can staple output."), 25 | CanDoFastCopiesInHardware(6, "Can do fast copies in hardware."), 26 | CanDoFastCopyCollationInHardware(7, "Can do fast copy collation in hardware."), 27 | CanPunchOutput(8, "Can punch output."), 28 | CanCoverOutput(9, "Can cover output."), 29 | CanBindOutput(10, "Can bind output."), 30 | CanSortOutput(11, "Can sort output."), 31 | CanHandleMediaUpToUsLegalA4(12, "Can handle media up to US-Legal/A4."), 32 | CanHandleMediaFromUsLegalA4toIsoCA2(13, "Can handle media from US-Legal/A4 to ISO-C/A2."), 33 | CanHandleMediaLargerThanIsoCA2(14, "Can handle media larger than ISO-C/A2."), 34 | CanHandleUserDefinedMediaSizes(15, "Can handle user-defined media sizes."), 35 | IsAnImplicitServerGeneratedClass(16, "Is an implicit (server-generated) class."), 36 | IsTheDefaultPrinterOnTheNetwork(17, "Is the a default printer on the network."), 37 | IsAFacsimileDevice(18, "Is a facsimile device."), 38 | IsRejectingJobs(19, "Is rejecting jobs."), 39 | DeleteThisQueue(20, "Delete this queue."), 40 | QueueIsNotShared(21, "Queue is not shared."), 41 | QueueRequiresAuthentication(22, "Queue requires authentication."), 42 | QueueSupportsCUPSCommandFiles(23, "Queue supports CUPS command files."), 43 | QueueWasAutomaticallyDiscoveredAndAdded(24, "Queue was automatically discovered and added."), 44 | QueueIsAScannerWithNoPrintingCapabilities(25, "Queue is a scanner with no printing capabilities."), 45 | QueueIsAPrinterWithScanningCapabilities(26, "Queue is a printer with scanning capabilities."), 46 | QueueIsAPrinterWith3DCapabilities(27, "Queue is a printer with 3D capabilities."); 47 | 48 | val value: Int = 1 shl bitNumber // set relevant bit 49 | } 50 | 51 | fun toSet(): Set = 52 | Capability.values().filter { value.and(it.value) != 0 }.toSet() 53 | 54 | fun contains(capability: Capability) = 55 | toSet().contains(capability) 56 | 57 | override fun toString() = "$value (${toSet().joinToString(",")})" 58 | 59 | fun log(logger: Logger, level: Level = INFO) = logger.run { 60 | log(level) { "PRINTER-TYPE 0x%08X capabilities:".format(value) } 61 | toSet().forEach { log(level) { "* ${it.description}" } } 62 | } 63 | 64 | override fun buildIppAttribute(printerAttributes: IppAttributesGroup) = 65 | IppAttribute("printer-type", IppTag.Enum, value) 66 | 67 | companion object { 68 | fun fromAttributes(attributes: IppAttributesGroup) = 69 | PrinterType(attributes.getValue("printer-type")) 70 | 71 | fun fromCapabilities(capabilities: Set) = 72 | capabilities.map { it.value }.reduce { c1, c2 -> c1 + c2 }.let { PrinterType(it) } 73 | } 74 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/attributes/MediaCollection.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.attributes 2 | 3 | /** 4 | * Copyright (c) 2020-2025 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.* 8 | import de.gmuth.ipp.core.IppTag.BegCollection 9 | import de.gmuth.ipp.core.IppTag.NameWithoutLanguage 10 | import java.util.logging.Logger 11 | 12 | // https://ftp.pwg.org/pub/pwg/candidates/cs-ippjobext21-20230210-5100.7.pdf 6.3 13 | @SuppressWarnings("kotlin:S1192") 14 | data class MediaCollection( 15 | var size: MediaSize? = null, 16 | var margin: MediaMargin? = null, 17 | var source: MediaSource? = null, 18 | var type: String? = null, // media-type (type2 keyword | name(MAX)) [PWG5100.7] 19 | var sizeName: String? = null, // media-size-name (type2 keyword | name(MAX)) [PWG5100.7] 20 | var key: String? = null, 21 | var sourceProperties: MediaSourceProperties? = null, 22 | ) : IppAttributeBuilder { 23 | 24 | private val logger = Logger.getLogger(javaClass.name) 25 | 26 | override fun buildIppAttribute(printerAttributes: IppAttributesGroup): IppAttribute<*> { 27 | val mediaSize = size // conflict with IppCollection.size 28 | return IppAttribute("media-col", BegCollection, IppCollection().apply { 29 | sizeName?.let { addAttribute("media-size-name", NameWithoutLanguage, it) } 30 | type?.let { addAttribute("media-type", NameWithoutLanguage, it) } 31 | key?.let { addAttribute("media-key", NameWithoutLanguage, it) } 32 | mediaSize?.let { add(it.buildIppAttribute(printerAttributes)) } 33 | source?.let { add(it.buildIppAttribute(printerAttributes)) } 34 | margin?.let { addAll(it.buildIppAttributes()) } // add up to 4 attributes 35 | }) 36 | } 37 | 38 | override fun toString() = StringBuilder("MEDIA").apply { 39 | key?.let { append(" key=$it") } 40 | size?.let { append(" size=$it") } 41 | sizeName?.let { append(" size-name=$it") } 42 | margin?.let { append(" margin=$it") } 43 | source?.let { append(" source=$it") } 44 | type?.let { append(" type=$it") } 45 | sourceProperties?.let { append(" source-properties=$it") } 46 | }.toString() 47 | 48 | fun sizeEqualsByDimensions(mediaSize: MediaSize) = 49 | size?.equalsByDimensions(mediaSize) ?: false 50 | 51 | companion object { 52 | fun fromIppCollection(mediaIppCollection: IppCollection) = MediaCollection().apply { 53 | for (member in mediaIppCollection.members) with(member) { 54 | when (name) { 55 | "media-key" -> key = getKeywordOrName() 56 | "media-size" -> setMediaSize(value as IppCollection) 57 | "media-size-name" -> sizeName = getKeywordOrName() 58 | "media-type" -> type = getKeywordOrName() 59 | "media-source" -> source = MediaSource(getKeywordOrName()) 60 | "media-source-properties" -> sourceProperties = 61 | MediaSourceProperties.fromIppCollection(value as IppCollection) 62 | 63 | else -> if (!isMediaMargin()) logger.warning { "Ignored unsupported member: $member" } 64 | } 65 | } 66 | if (mediaIppCollection.members.any { it.isMediaMargin() }) { 67 | margin = MediaMargin.fromIppCollection(mediaIppCollection) 68 | } 69 | } 70 | } 71 | 72 | private fun MediaCollection.setMediaSize(ippCollection: IppCollection) { 73 | if (ippCollection.getMember("x-dimension").tag == IppTag.Integer 74 | && ippCollection.getMember("y-dimension").tag == IppTag.Integer) 75 | size = MediaSize.fromIppCollection(ippCollection) 76 | else 77 | logger.warning { "Ignored unsupported media-size: " + ippCollection } 78 | } 79 | 80 | private fun IppAttribute<*>.isMediaMargin() = Regex("media-.*-margin").matches(name) 81 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/client/IppEventNotification.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.client 2 | 3 | /** 4 | * Copyright (c) 2021-2024 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.attributes.JobState 8 | import de.gmuth.ipp.attributes.PrinterState 9 | import de.gmuth.ipp.core.IppAttributesGroup 10 | import de.gmuth.ipp.core.IppString 11 | import java.net.URI 12 | import java.nio.charset.Charset 13 | import java.time.ZonedDateTime 14 | import java.util.logging.Level 15 | import java.util.logging.Logger 16 | 17 | class IppEventNotification( 18 | val subscription: IppSubscription, 19 | val attributes: IppAttributesGroup 20 | ) { 21 | val charset: Charset 22 | get() = attributes.getValue("notify-charset") 23 | 24 | val naturalLanguage: String 25 | get() = attributes.getValue("notify-natural-language") 26 | 27 | val subscriptionId: Int 28 | get() = attributes.getValue("notify-subscription-id") 29 | 30 | val sequenceNumber: Int 31 | get() = attributes.getValue("notify-sequence-number") 32 | 33 | val subscribedEvent: String 34 | get() = attributes.getValue("notify-subscribed-event") 35 | 36 | val text: IppString 37 | get() = attributes.getValue("notify-text") 38 | 39 | val jobId: Int 40 | get() = attributes.getValue("notify-job-id") 41 | 42 | val jobState: JobState 43 | get() = JobState.fromAttributes(attributes) 44 | 45 | val jobStateReasons: List 46 | get() = attributes.getValues("job-state-reasons") 47 | 48 | val jobImpressionsCompleted: Int 49 | get() = attributes.getValue("job-impressions-completed") 50 | 51 | val printerUri: URI 52 | get() = attributes.getValue("notify-printer-uri") 53 | 54 | val printerName: IppString 55 | get() = attributes.getValue("printer-name") 56 | 57 | val printerState: PrinterState 58 | get() = PrinterState.fromInt(attributes.getValue("printer-state")) 59 | 60 | val printerStateReasons: List 61 | get() = attributes.getValues("printer-state-reasons") 62 | 63 | val printerIsAcceptingJobs: Boolean 64 | get() = attributes.getValue("printer-is-accepting-jobs") 65 | 66 | // let a Recipient know when the Event Notification occurred (RFC 3996 5.2.2) 67 | val printerUpTime: ZonedDateTime 68 | get() = attributes.getValueAsZonedDateTime("printer-up-time") 69 | 70 | // Get job of event origin 71 | fun getJob() = subscription.printer.getJob(jobId) 72 | 73 | // Get printer of event origin 74 | fun getPrinter(getPrinterAttributesOnInit: Boolean = false) = IppPrinter( 75 | printerUri, 76 | ippClient = subscription.printer.ippClient, 77 | getPrinterAttributesOnInit = getPrinterAttributesOnInit 78 | ) 79 | 80 | @SuppressWarnings("kotlin:S3776") 81 | override fun toString() = StringBuilder().run { 82 | append(printerUpTime.toLocalDateTime()) 83 | append(" EventNotification #$sequenceNumber") 84 | append(" [$subscribedEvent] $text") 85 | with(attributes) { 86 | if (containsKey("notify-job-id")) append(", job #$jobId") 87 | if (containsKey("job-state")) append(", job-state=$jobState") 88 | if (containsKey("job-state-reasons")) append(" (reasons=${jobStateReasons.joinToString(",")})") 89 | if (containsKey("printer-name")) append(", printer-name=$printerName") 90 | if (containsKey("printer-state")) append(", printer-state=$printerState") 91 | if (containsKey("printer-state-reasons")) append(" (reasons=${printerStateReasons.joinToString(",")})") 92 | } 93 | toString() 94 | } 95 | 96 | @JvmOverloads 97 | fun log(logger: Logger, level: Level = Level.INFO) = 98 | attributes.log(logger, level, title = "EVENT_NOTIFICATION #$sequenceNumber [$subscribedEvent] $text") 99 | 100 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/core/IppStatus.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.core 2 | 3 | /** 4 | * Copyright (c) 2020-2023 Gerhard Muth 5 | */ 6 | 7 | import java.util.logging.Level 8 | import java.util.logging.Level.* 9 | 10 | // https://www.rfc-editor.org/rfc/rfc8011.html#appendix-B 11 | // https://www.iana.org/assignments/ipp-registrations/ipp-registrations.xml#ipp-registrations-11 12 | 13 | enum class IppStatus(val code: Int) { 14 | 15 | SuccessfulOk(0x0000), 16 | SuccessfulOkIgnoredOrSubstitutedAttributes(0x0001), 17 | SuccessfulOkConflictingAttributes(0x0002), 18 | SuccessfulOkIgnoredSubscriptions(0x0003), 19 | SuccessfulOkTooManyEvents(0x0005), 20 | SuccessfulOkEventsComplete(0x0007), 21 | 22 | ClientErrorBadRequest(0x0400), 23 | ClientErrorForbidden(0x0401), 24 | ClientErrorNotAuthenticated(0x0402), 25 | ClientErrorNotAuthorized(0x0403), 26 | ClientErrorNotPossible(0x0404), 27 | ClientErrorTimeout(0x0405), 28 | ClientErrorNotFound(0x0406), 29 | ClientErrorGone(0x0407), 30 | ClientErrorRequestEntityTooLarge(0x0408), 31 | ClientErrorRequestValueTooLarge(0x0409), 32 | ClientErrorDocumentFormatNotSupported(0x040A), 33 | ClientErrorAttributesOrValuesNotSupported(0x040B), 34 | ClientErrorUriSchemeNotSupported(0x040C), 35 | ClientErrorCharsetNotSupported(0x040D), 36 | ClientErrorConflictingAttribute(0x040E), 37 | ClientErrorCompressionNotSupported(0x040F), 38 | ClientErrorCompressionError(0x0410), 39 | ClientErrorDocumentFormatError(0x0411), 40 | ClientErrorDocumentAccessError(0x0412), 41 | ClientErrorAttributesNotSettable(0x0413), // https://datatracker.ietf.org/doc/html/rfc3380#page-29 42 | ClientErrorIgnoredAllSubscriptions(0x0414), // https://datatracker.ietf.org/doc/html/rfc3995#page-71 43 | ClientErrorTooManySubscriptions(0x0415), // https://datatracker.ietf.org/doc/html/rfc3995#page-72 44 | ClientErrorDocumentPasswordError(0x0418), // https://ftp.pwg.org/pub/pwg/candidates/cs-ippjobprinterext3v10-20120727-5100.13.pdf 45 | ClientErrorDocumentPermissionError(0x0419), 46 | ClientErrorDocumentSecurityError(0x041A), 47 | ClientErrorDocumentUnprintableError(0x041B), 48 | ClientErrorAccountInfoNeeded(0x041C), // https://ftp.pwg.org/pub/pwg/candidates/cs-ipptrans10-20131108-5100.16.pdf 49 | ClientErrorAccountClosed(0x041D), 50 | ClientErrorAccountLimitReached(0x041E), 51 | ClientErrorAccountAuthorizationFailed(0x041F), 52 | ClientErrorNotFetchable(0x0420), // https://ftp.pwg.org/pub/pwg/candidates/cs-ippinfra10-20150619-5100.18.pdf 53 | 54 | ServerErrorInternalError(0x0500), 55 | ServerErrorOperationNotSupported(0x0501), 56 | ServerErrorServiceUnavailable(0x0502), 57 | ServerErrorVersionNotSupported(0x0503), 58 | ServerErrorDeviceError(0x0504), 59 | ServerErrorTemporaryError(0x0505), 60 | ServerErrorNotAcceptingJobs(0x0506), 61 | ServerErrorBusy(0x0507), 62 | ServerErrorJobCanceled(0x0508), 63 | ServerErrorMultipleDocumentJobsNotSupported(0x0509), 64 | ServerErrorPrinterIsDeactivated(0x050A), // https://datatracker.ietf.org/doc/html/rfc3998#page-23 65 | ServerErrorTooManyJobs(0x050B), // https://ftp.pwg.org/pub/pwg/candidates/cs-ippjobext20-20190816-5100.7.pdf 66 | ServerErrorTooManyDocuments(0x050C); 67 | 68 | fun isSuccessful() = code in 0x0000..0x00FF 69 | fun isClientError() = code in 0x0400..0x04FF 70 | fun isServerError() = code in 0x0500..0x05FF 71 | 72 | override fun toString() = name 73 | .replace(Regex("(.)([A-Z])")) { it.groups[1]!!.value + "-" + it.groups[2]!!.value } 74 | .lowercase() 75 | 76 | fun logLevel(): Level = when { 77 | isClientError() -> WARNING 78 | isServerError() -> SEVERE 79 | else -> INFO 80 | } 81 | 82 | companion object { 83 | fun fromInt(code: Int): IppStatus = 84 | values().find { it.code == code } ?: throw IppException("Unknown status code %04X".format(code)) 85 | } 86 | 87 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/ipp/core/IppMessageTests.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.core 2 | 3 | /** 4 | * Copyright (c) 2020-2025 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.log.Logging 8 | import java.io.ByteArrayInputStream 9 | import java.io.ByteArrayOutputStream 10 | import java.nio.file.Files 11 | import java.nio.file.Files.createTempFile 12 | import java.util.logging.Logger.getLogger 13 | import kotlin.test.* 14 | 15 | internal fun IppMessage.readTestResource(resource: String) = 16 | read(javaClass.getResourceAsStream(resource)) 17 | 18 | class IppMessageTests { 19 | 20 | private val logger = getLogger(javaClass.name) 21 | 22 | @BeforeTest 23 | fun setUp() { Logging.configure() } 24 | 25 | private val message = object : IppMessage() { 26 | override val codeDescription: String 27 | get() = "codeDescription" 28 | } 29 | 30 | @Test 31 | fun setVersionFails() { 32 | assertFailsWith { message.version = "wrong" } 33 | } 34 | 35 | @Test 36 | fun getSingleAttributesGroupFails() { 37 | assertFailsWith { message.getSingleAttributesGroup(IppTag.Operation) } 38 | } 39 | 40 | @Test 41 | fun containsGroup() { 42 | assertFalse(message.containsGroup(IppTag.Job)) 43 | } 44 | 45 | @Test 46 | fun hasNoDocument() { 47 | assertFalse(message.hasDocument()) 48 | } 49 | 50 | @Test 51 | fun writeFile() { 52 | with(message) { 53 | createAttributesGroup(IppTag.Operation).attribute("attributes-charset", IppTag.Charset, Charsets.UTF_8) 54 | version = "1.1" 55 | requestId = 5 56 | code = 0 57 | documentInputStream = ByteArrayInputStream("01 02 03".toByteArray()) 58 | val tmpFile = createTempFile("test", null) 59 | try { 60 | write(tmpFile, true) 61 | } finally { 62 | Files.delete(tmpFile) 63 | } 64 | assertEquals(38, rawBytes!!.size) 65 | assertFailsWith { 66 | write(ByteArrayOutputStream()) 67 | }.apply { 68 | logger.info(toString()) 69 | } 70 | toString() // cover toString 71 | log(logger) // cover log 72 | } 73 | } 74 | 75 | @Test 76 | fun saveDocumentAndIpp() { 77 | with(message) { 78 | createAttributesGroup(IppTag.Operation).attribute("attributes-charset", IppTag.Charset, Charsets.UTF_8) 79 | version = "1.1" 80 | requestId = 7 81 | code = 0 82 | documentInputStream = "Lorem ipsum dolor sit amet".byteInputStream() 83 | val tmpFile0 = createTempFile("test", null) 84 | val tmpFile1 = createTempFile("test", null) 85 | val tmpFile2 = createTempFile("test", null) 86 | 87 | try { 88 | IppMessage.keepDocumentCopy = true 89 | assertTrue(hasDocument()) 90 | write(Files.newOutputStream(tmpFile0), true) 91 | saveDocument(tmpFile1) 92 | assertEquals(26, Files.size(tmpFile1)) 93 | val ippBytes = encode(appendDocumentIfAvailable = false) // trigger saving raw bytes 94 | assertEquals(38, ippBytes.size) 95 | saveBytes(tmpFile2) 96 | assertEquals(38, Files.size(tmpFile2)) 97 | } finally { 98 | Files.delete(tmpFile1) 99 | Files.delete(tmpFile2) 100 | } 101 | } 102 | } 103 | 104 | @Test 105 | fun withoutRawBytes() { 106 | message.log(logger) 107 | assertFailsWith { // missing raw bytes 108 | message.saveBytes(createTempFile("rawbytes", null)) 109 | } 110 | } 111 | 112 | @Test 113 | fun writeTest() { 114 | message.saveText(createTempFile("text", null)) 115 | } 116 | 117 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/core/IppTag.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.core 2 | 3 | /** 4 | * Copyright (c) 2020-2025 Gerhard Muth 5 | */ 6 | 7 | // RFC 8010 and RFC 3380 8 | 9 | @Suppress("kotlin:S100") 10 | enum class IppTag( 11 | val code: Byte, 12 | val registeredName: String, 13 | val valueHasValidClass: (Any) -> kotlin.Boolean = { true } 14 | ) { 15 | // Delimiter tags 16 | // https://www.iana.org/assignments/ipp-registrations/ipp-registrations.xml#ipp-registrations-7 17 | Operation(0x01, "operation-attributes-tag"), 18 | Job(0x02, "job-attributes-tag"), 19 | End(0x03, "end-of-attributes-tag"), 20 | Printer(0x04, "printer-attributes-tag"), 21 | Unsupported(0x05, "unsupported-attributes-tag"), // group 22 | Subscription(0x06, "subscription-attributes-tag"), 23 | EventNotification(0x07, "event-notification-attributes-tag"), 24 | Resource(0x08, "resource-attributes-tag"), 25 | Document(0x09, "document-attributes-tag"), 26 | System(0x0A, "system-attributes-tag"), 27 | 28 | // Out-of-band tags 29 | // https://www.iana.org/assignments/ipp-registrations/ipp-registrations.xml#ipp-registrations-8 30 | Unsupported_(0x10, "unsupported"), // value 31 | Unknown(0x12, "unknown"), 32 | NoValue(0x13, "no-value"), 33 | NotSettable(0x15, "not-settable"), 34 | DeleteAttribute(0x16, "delete-attribute"), 35 | AdminDefine(0x17, "admin-define"), 36 | 37 | //https://www.iana.org/assignments/ipp-registrations/ipp-registrations.xml#ipp-registrations-9 38 | 39 | // Integer 40 | Integer(0x21, "integer", { it is Number }), 41 | Boolean(0x22, "boolean", { it is kotlin.Boolean }), 42 | Enum(0x23, "enum", { it is Number }), 43 | 44 | // Misc 45 | OctetString(0x30, "octetString", { it is String }), 46 | DateTime(0x31, "dateTime", { it is IppDateTime }), 47 | Resolution(0x32, "resolution", { it is IppResolution }), 48 | RangeOfInteger(0x33, "rangeOfInteger", { it is IntRange }), 49 | BegCollection(0x34, "collection", { it is IppCollection }), 50 | TextWithLanguage(0x35, "textWithLanguage", { it is IppString }), 51 | NameWithLanguage(0x36, "nameWithLanguage", { it is IppString }), 52 | EndCollection(0x37, "endCollection"), 53 | 54 | // Text 55 | TextWithoutLanguage(0x41, "textWithoutLanguage", { it is IppString || it is String }), 56 | NameWithoutLanguage(0x42, "nameWithoutLanguage", { it is IppString || it is String }), 57 | Keyword(0x44, "keyword", { it is String }), 58 | Uri(0x45, "uri", { it is java.net.URI }), 59 | UriScheme(0x46, "uriScheme", { it is String }), 60 | Charset(0x47, "charset", { it is java.nio.charset.Charset }), 61 | NaturalLanguage(0x48, "naturalLanguage", { it is String }), 62 | MimeMediaType(0x49, "mimeMediaType", { it is String }), 63 | MemberAttrName(0x4A, "memberAttrName", { it is String }); 64 | 65 | fun isDelimiterTag() = code < 0x10 66 | fun isGroupTag() = code < 0x10 && this != End 67 | fun isValueTag() = 0x10 <= code 68 | fun isOutOfBandTag() = code in 0x10..0x1f 69 | fun isMemberAttrName() = this == MemberAttrName 70 | fun isMemberAttrValue() = this != MemberAttrName && isValueTag() && this != EndCollection 71 | fun isValueTagAndIsNotOutOfBandTag() = isValueTag() && !isOutOfBandTag() 72 | 73 | override fun toString() = registeredName 74 | 75 | fun registeredSyntax() = when (this) { 76 | // IANA registered syntax doesn't care about language 77 | NameWithoutLanguage, NameWithLanguage -> "name" 78 | TextWithoutLanguage, TextWithLanguage -> "text" 79 | else -> registeredName 80 | } 81 | 82 | fun validateValueClass(value: Any) { 83 | if (!valueHasValidClass(value)) throw IppException("Value class ${value::class.java.name} not valid for tag $this") 84 | } 85 | 86 | companion object { 87 | fun fromByte(code: Byte): IppTag = 88 | values().singleOrNull { it.code == code } ?: throw IppException("Unknown tag 0x%02X".format(code)) 89 | 90 | fun fromString(name: String): IppTag = 91 | values().singleOrNull { it.registeredName == name } ?: throw IppException("Unknown tag name '$name'") 92 | } 93 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/client/IppRequestExchangedEvent.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.client 2 | 3 | /** 4 | * Copyright (c) 2024-2025 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.client.IppDocument.Companion.getDocumentFormatFilenameExtension 8 | import de.gmuth.ipp.core.IppRequest 9 | import de.gmuth.ipp.core.IppResponse 10 | import java.io.File 11 | import java.io.PrintWriter 12 | import java.nio.file.Files 13 | import java.nio.file.Path 14 | import java.time.LocalDateTime.now 15 | import java.time.format.DateTimeFormatter.ofPattern 16 | import java.util.logging.Logger 17 | import kotlin.io.path.inputStream 18 | import java.nio.file.Files.newBufferedWriter 19 | import kotlin.io.path.createDirectories 20 | 21 | class IppRequestExchangedEvent(val request: IppRequest, val response: IppResponse) { 22 | 23 | constructor(requestPath: Path, responsePath: Path) : this( 24 | IppRequest().apply { read(requestPath.inputStream()) }, 25 | IppResponse().apply { read(responsePath.inputStream()) } 26 | ) 27 | 28 | private val logger = Logger.getLogger(IppRequestExchangedEvent::class.qualifiedName) 29 | 30 | override fun toString() = 31 | "#%04d %-60s = #%04d %s".format(request.requestId, request, response.requestId, response) 32 | 33 | fun save( 34 | directory: Path, 35 | saveEvent: Boolean = false, 36 | saveDocument: Boolean = false, 37 | saveRawMessages: Boolean = true, 38 | maxFilenameLength: Int = 200 39 | ) { 40 | logger.fine("Save files in $directory") 41 | try { 42 | val connectionDirectory = directory.resolve( 43 | request.connectionName() 44 | .replace(File.separator, "_") 45 | .replace(":", "_") 46 | ) 47 | 48 | fun filename(extension: String) = StringBuilder().run { 49 | append(ofPattern("HHmmssSSS").format(now())) 50 | append(" #%04d".format(request.requestId)) 51 | append(" $request = $response") 52 | toString() 53 | .take(maxFilenameLength - 1 - extension.length) 54 | .plus(".$extension") 55 | .replace(File.separator, "_") 56 | } 57 | 58 | fun fileWithExtension(extension: String) = 59 | connectionDirectory.resolve(filename(extension)) 60 | 61 | // Save raw message bytes 62 | if (saveRawMessages) { 63 | logger.fine { "Save raw IPP messages" } 64 | request.saveBytes(fileWithExtension("req")) 65 | response.saveBytes(fileWithExtension("res")) 66 | } 67 | 68 | // Save decoded request and response to single text file 69 | if (saveEvent) fileWithExtension("txt").run { 70 | parent?.createDirectories() 71 | newBufferedWriter(this).use { 72 | val printWriter = PrintWriter(it) 73 | request.writeText(printWriter, "File: $this") 74 | response.writeText(printWriter) 75 | printWriter.println("---------------------------------------------------------------------") 76 | request.httpUserAgent?.run { printWriter.println("UserAgent: $this") } 77 | response.httpServer?.run { printWriter.println("Server: $this") } 78 | } 79 | logger.fine("Saved ${toAbsolutePath()} (${Files.size(this)} bytes)") 80 | } 81 | 82 | // Save document 83 | if (saveDocument && request.hasDocument()) { 84 | logger.fine { "Save document" } 85 | val filenameExtension = with(request) { 86 | if (operationGroup.containsKey("document-format")) getDocumentFormatFilenameExtension() else "bin" 87 | } 88 | request.saveDocument(fileWithExtension(filenameExtension)) 89 | } 90 | 91 | } catch (throwable: Throwable) { 92 | logger.severe("Failed to save: $throwable") 93 | } 94 | } 95 | 96 | private fun IppRequest.getDocumentFormatFilenameExtension() = 97 | getDocumentFormatFilenameExtension(operationGroup) 98 | 99 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/client/IppDocument.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.client 2 | 3 | /** 4 | * Copyright (c) 2021-2025 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppAttributesGroup 8 | import de.gmuth.ipp.core.IppException 9 | import de.gmuth.ipp.core.IppString 10 | import java.io.File 11 | import java.io.IOException 12 | import java.io.InputStream 13 | import java.io.OutputStream 14 | import java.nio.file.Files 15 | import java.nio.file.Files.newOutputStream 16 | import java.nio.file.Path 17 | import java.util.logging.Level 18 | import java.util.logging.Logger 19 | import java.util.logging.Logger.getLogger 20 | import kotlin.io.path.createDirectories 21 | import kotlin.io.path.isRegularFile 22 | 23 | @Suppress("kotlin:S1192") 24 | class IppDocument( 25 | val job: IppJob, 26 | val attributes: IppAttributesGroup, 27 | private val inputStream: InputStream 28 | ) { 29 | 30 | companion object { 31 | fun getFilenameExtension(mediaType: String) = when (mediaType) { 32 | "application/postscript", "application/vnd.cups-postscript", "application/vnd.adobe-reader-postscript" -> "ps" 33 | "application/pdf", "application/vnd.cups-pdf" -> "pdf" 34 | "application/octet-stream" -> "bin" 35 | "text/plain" -> "txt" 36 | else -> mediaType.split("/")[1] 37 | } 38 | 39 | fun getDocumentFormatFilenameExtension(attributes: IppAttributesGroup) = 40 | getFilenameExtension(attributes.getValue("document-format")) 41 | } 42 | 43 | private val logger = getLogger(javaClass.name) 44 | 45 | val number: Int 46 | get() = attributes.getValue("document-number") 47 | 48 | val format: String 49 | get() = attributes.getValue("document-format") 50 | 51 | val name: IppString 52 | get() = attributes.getValue("document-name") 53 | 54 | var file: Path? = null 55 | 56 | fun readBytes() = inputStream.readBytes() 57 | .also { logger.fine { "Read ${it.size} bytes of $this" } } 58 | 59 | fun filename() = StringBuilder().apply { 60 | var extension: String? = getFilenameExtension(format) 61 | job.run { 62 | append("job-$id") 63 | getNumberOfDocumentsOrDocumentCount().let { if (it > 1) append("-doc-$it") } 64 | getOriginatingUserNameOrAppleJobOwnerOrNull()?.let { append("-$it") } 65 | if (attributes.containsKey("com.apple.print.JobInfo.PMApplicationName")) { 66 | append("-${attributes.getValue("com.apple.print.JobInfo.PMApplicationName")}") 67 | } 68 | getJobNameOrDocumentNameSuppliedOrAppleJobNameOrNull()?.run { 69 | append("-${take(100)}") 70 | if (lowercase().endsWith(".$extension")) extension = null 71 | } 72 | } 73 | extension?.let { append(".$it") } 74 | }.toString().replace(File.separator, "_") 75 | 76 | fun copyTo(outputStream: OutputStream) = 77 | inputStream.copyTo(outputStream) 78 | 79 | fun save( 80 | directory: Path = job.printer.printerDirectory, 81 | filename: String = filename(), 82 | overwrite: Boolean = true 83 | ) = directory.resolve(filename).also { 84 | it.parent?.createDirectories() 85 | if (it.isRegularFile() && !overwrite) throw IOException("File '$it' already exists") 86 | copyTo(newOutputStream(it)) 87 | this.file = it 88 | logger.info { "Saved $file ${if (attributes.containsKey("document-format")) "($format)" else ""}" } 89 | } 90 | 91 | fun runtimeExecCommand(commandToHandleFile: String) = 92 | if (file == null) throw IppException("Missing file to handle.") 93 | else Runtime.getRuntime().exec(arrayOf(commandToHandleFile, file!!.toAbsolutePath().toString())) 94 | 95 | override fun toString() = StringBuilder("Document #$number").run { 96 | append(" ($format) of job #${job.id}") 97 | if (attributes.containsKey("document-name")) append(" '$name'") 98 | if (file != null) append(": $file (${Files.size(file!!)} bytes)") 99 | toString() 100 | } 101 | 102 | @JvmOverloads 103 | fun log(logger: Logger, level: Level = Level.INFO) = 104 | attributes.log(logger, level, title = "DOCUMENT #$number") 105 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/core/IppRequest.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.core 2 | 3 | /** 4 | * Copyright (c) 2020-2025 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.client.IppOperationException 8 | import de.gmuth.ipp.core.IppStatus.ClientErrorBadRequest 9 | import de.gmuth.ipp.core.IppTag.* 10 | import java.net.URI 11 | import java.nio.charset.Charset 12 | import java.time.Duration 13 | import java.util.logging.Level 14 | import java.util.logging.Logger 15 | 16 | @Suppress("kotlin:S1192") 17 | class IppRequest : IppMessage { 18 | private val logger = Logger.getLogger(javaClass.name) 19 | 20 | var httpUserAgent: String? = null 21 | 22 | val printerOrJobUri: URI 23 | @SuppressWarnings("kotlin:S1192") 24 | get() = operationGroup.run { 25 | when { 26 | containsKey("printer-uri") -> getValueAsURI("printer-uri") 27 | containsKey("job-uri") -> getValueAsURI("job-uri") 28 | else -> throw IppException("Missing 'printer-uri' or 'job-uri' in IppRequest") 29 | .also { log(logger, Level.WARNING) } 30 | } 31 | } 32 | 33 | override val codeDescription: String 34 | get() = operation.toString() 35 | 36 | val operation: IppOperation 37 | get() = IppOperation.fromInt(code!!) 38 | 39 | val requestedAttributes: List 40 | get() = operationGroup.getValues("requested-attributes") 41 | 42 | val requestingUserName: String 43 | get() = operationGroup.getValue("requesting-user-name").text 44 | 45 | @JvmOverloads 46 | constructor(userAgent: String? = null) : super() { 47 | httpUserAgent = userAgent 48 | } 49 | 50 | @JvmOverloads 51 | constructor( 52 | operation: IppOperation, 53 | printerUri: URI? = null, 54 | requestedAttributes: Collection? = null, 55 | requestingUserName: String? = null, 56 | version: String = "2.0", 57 | requestId: Int = 1, 58 | charset: Charset = Charsets.UTF_8, 59 | naturalLanguage: String = "en", 60 | userAgent: String? = null 61 | ) : super(version, requestId, charset, naturalLanguage) { 62 | code = operation.code 63 | operationGroup.run { 64 | printerUri?.let { attribute("printer-uri", Uri, it) } 65 | requestedAttributes?.let { attribute("requested-attributes", Keyword, it) } 66 | requestingUserName?.let { attribute("requesting-user-name", NameWithoutLanguage, IppString(it)) } 67 | } 68 | httpUserAgent = userAgent 69 | } 70 | 71 | @JvmOverloads 72 | fun createSubscriptionAttributesGroup( 73 | notifyEvents: Collection? = null, 74 | notifyLeaseDuration: Duration? = null, 75 | notifyTimeInterval: Duration? = null, 76 | notifyJobId: Int? = null 77 | ) = createAttributesGroup(Subscription).apply { 78 | attribute("notify-pull-method", Keyword, "ippget") 79 | notifyJobId?.let { attribute("notify-job-id", Integer, it) } 80 | notifyEvents?.let { attribute("notify-events", Keyword, it) } 81 | notifyTimeInterval?.let { attribute("notify-time-interval", Integer, it.toSeconds()) } 82 | notifyLeaseDuration?.let { attribute("notify-lease-duration", Integer, it.toSeconds()) } 83 | } 84 | 85 | fun decodeOrThrowIppOperationException(byteArray: ByteArray, status: IppStatus = ClientErrorBadRequest) = 86 | try { 87 | apply { decode(byteArray) } 88 | } catch (throwable: Throwable) { 89 | logger.severe { "Decoding IPP request failed: $throwable" } 90 | throw IppOperationException(this, status, "Failed to decode IPP request", cause = throwable) 91 | } 92 | 93 | fun connectionName() = 94 | "${httpUserAgent ?: "unknown"} -- $printerOrJobUri".replace("//", "") 95 | 96 | override fun toString() = StringBuilder().apply { 97 | append(operation) 98 | val details = attributesGroups 99 | .filter { group -> group.tag != Operation || !operation.name.contains("Attributes") } 100 | .map { "${it.size} ${it.tag.name.lowercase()} attributes" } 101 | .toMutableList() 102 | getAttributeValuesOrNull>(Operation, "requested-attributes") 103 | ?.run { details.add("$size requested-attributes") } 104 | if (details.isNotEmpty()) { 105 | append(details.joinToString(", ", " (", ")")) 106 | } 107 | }.toString() 108 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/client/IppValueSupport.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.client 2 | 3 | /** 4 | * Copyright (c) 2020-2024 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.* 8 | import de.gmuth.ipp.core.IppTag.* 9 | import java.util.logging.Logger.getLogger 10 | 11 | // ------------------------------------------------------ 12 | // Attribute value checking based on printer capabilities 13 | // ------------------------------------------------------ 14 | 15 | object IppValueSupport { 16 | 17 | private val logger = getLogger(javaClass.name) 18 | 19 | fun checkIfValueIsSupported( 20 | printerAttributes: IppAttributesGroup, attribute: IppAttribute<*>, 21 | throwIfSupportedAttributeIsNotAvailable: Boolean 22 | ) { 23 | val supportedAttribute = printerAttributes["${attribute.name}-supported"] 24 | if (supportedAttribute == null) logger.warning { "${attribute.name}-supported not available in printer attributes" } 25 | else checkIfValueIsSupported(printerAttributes, attribute.name, attribute.value as Any, throwIfSupportedAttributeIsNotAvailable) 26 | } 27 | 28 | fun checkIfValueIsSupported( 29 | printerAttributes: IppAttributesGroup, 30 | attributeName: String, 31 | value: Any, 32 | throwIfSupportedAttributeIsNotAvailable: Boolean 33 | ) { 34 | require(printerAttributes.tag == Printer) { "Printer attributes group expected" } 35 | if (printerAttributes.isEmpty()) return 36 | 37 | if (value is Collection<*>) { // instead of providing another signature just check collections iteratively 38 | for (collectionValue in value) { 39 | checkIfValueIsSupported(printerAttributes, attributeName, collectionValue!!, throwIfSupportedAttributeIsNotAvailable) 40 | } 41 | } else { 42 | val supportedAttributeName = "$attributeName-supported" 43 | if(!printerAttributes.containsKey(supportedAttributeName) && throwIfSupportedAttributeIsNotAvailable) 44 | throw IppException("Unable to check value '$value' because printer attribute '$supportedAttributeName' is not available.") 45 | isAttributeValueSupported(printerAttributes, attributeName, value) 46 | } 47 | } 48 | 49 | @SuppressWarnings("kotlin:S2175") 50 | private fun isAttributeValueSupported( 51 | printerAttributes: IppAttributesGroup, 52 | attributeName: String, 53 | value: Any 54 | ): Boolean? { 55 | val supportedAttributeName = "$attributeName-supported" 56 | val supportedAttribute = printerAttributes[supportedAttributeName] ?: return null 57 | val attributeValueIsSupported = when (supportedAttribute.tag) { 58 | IppTag.Boolean -> { // e.g. 'page-ranges-supported' 59 | supportedAttribute.value as Boolean 60 | } 61 | 62 | IppTag.Enum, Charset, NaturalLanguage, MimeMediaType, Keyword, Resolution -> when (supportedAttributeName) { 63 | "media-col-supported" -> with(value as IppCollection) { 64 | members 65 | .onEach { checkIfValueIsSupported(printerAttributes, it, false) } 66 | .filter { !supportedAttribute.values.contains(it.name) } 67 | .forEach { logger.warning { "media-col member unsupported: $it" } } 68 | supportedAttribute.values.containsAll(members.map { it.name }) 69 | } 70 | 71 | else -> supportedAttribute.values.contains(value) 72 | } 73 | 74 | Integer -> { 75 | if (supportedAttribute.is1setOf()) supportedAttribute.values.contains(value) 76 | else value is Int && value <= supportedAttribute.value as Int // e.g. 'job-priority-supported' 77 | } 78 | 79 | RangeOfInteger -> { 80 | value is Int && value in supportedAttribute.value as IntRange 81 | } 82 | 83 | else -> null 84 | } 85 | when (attributeValueIsSupported) { 86 | null -> logger.warning { "Unable to check if value '$value' is supported by $supportedAttribute" } 87 | true -> logger.finer { "$value is supported according to $supportedAttributeName" } 88 | false -> { 89 | logger.warning { "According to printer attributes value '${supportedAttribute.enumNameOrValue(value)}' is not supported for attribute '$attributeName'." } 90 | logger.warning { "$supportedAttribute" } 91 | } 92 | } 93 | return attributeValueIsSupported 94 | .also { logger.finest { "is $supportedAttributeName(${supportedAttribute.tag})? $value -> $attributeValueIsSupported" } } 95 | } 96 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/ipp/core/IppAttributeTests.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.core 2 | 3 | /** 4 | * Copyright (c) 2020-2023 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppTag.* 8 | import de.gmuth.log.Logging 9 | import java.io.File 10 | import java.util.logging.Logger.getLogger 11 | import kotlin.test.* 12 | 13 | class IppAttributeTests { 14 | 15 | init { 16 | Logging.configure() 17 | } 18 | 19 | private val logger = getLogger(javaClass.name) 20 | private val attribute = IppAttribute("printer-state-reasons", Keyword, "none") 21 | 22 | @Test 23 | fun constructorFailsDueToDelimiterTag() { 24 | assertFailsWith { IppAttribute("some-attribute-name", Operation) } 25 | } 26 | 27 | @Test 28 | fun constructorFailsDueToIllegalValueClass() { 29 | assertFailsWith { IppAttribute("some-attribute-name", TextWithLanguage, 1) } 30 | } 31 | 32 | @Test 33 | fun accessingSetAsValueFails() { 34 | assertFailsWith { attribute.value } 35 | } 36 | 37 | @Test 38 | fun additionalValue() { 39 | attribute.additionalValue(IppAttribute("", Keyword, "media-empty")) 40 | assertEquals(2, attribute.values.size) 41 | } 42 | 43 | @Test 44 | fun additionalValueIgnore1() { 45 | attribute.additionalValue(IppAttribute("", Integer, 2.1)) 46 | } 47 | 48 | @Test 49 | fun additionalValueFails2() { 50 | assertFailsWith { attribute.additionalValue(IppAttribute("", Keyword)) } 51 | } 52 | 53 | @Test 54 | fun additionalValueFails3() { 55 | assertFailsWith { attribute.additionalValue(IppAttribute("invalid-name", Keyword, "wtf")) } 56 | } 57 | 58 | @Test 59 | fun additionalValueIgnore2() { 60 | IppResponse().run { 61 | read(File("src/test/resources/invalidBrotherMediaTypeSupported.response")) 62 | assertEquals(1, printerGroup.getValues>("media-type-supported").size) 63 | } 64 | } 65 | 66 | @Test 67 | fun buildAttribute() { 68 | assertEquals(attribute, attribute.buildIppAttribute(IppAttributesGroup(Printer))) 69 | } 70 | 71 | @Test 72 | fun toStringNoValue() { 73 | attribute.values.clear() 74 | assertEquals("no-values", attribute.valuesToString()) 75 | } 76 | 77 | @Test 78 | fun toStringByteArrayNonEmpty() { 79 | val byteArrayAttribute = IppAttribute("", NoValue, ByteArray(2)) 80 | assertTrue(byteArrayAttribute.toString().endsWith("2 bytes")) 81 | } 82 | 83 | @Test 84 | fun toStringIntRange() { 85 | assertTrue(IppAttribute("int-range", RangeOfInteger, 1..2).toString().endsWith("1-2")) 86 | } 87 | 88 | @Test 89 | fun toStringTest() { 90 | assertEquals("christmas-time (integer) = 1608160102 (2020-12-16T23:08:22Z)", IppAttribute("christmas-time", Integer, 1608160102).toString()) 91 | } 92 | 93 | @Test 94 | fun enumNameOrValue() { 95 | assertEquals("processing", IppAttribute("printer-state", IppTag.Enum, 0).enumNameOrValue(4)) 96 | } 97 | 98 | @Test 99 | fun log() { 100 | // cover an output with more than 160 characters and a collection value 101 | IppAttribute("media-col".padEnd(160, '-'), BegCollection, IppCollection()).log(logger) 102 | } 103 | 104 | @Test 105 | fun isCollection() { 106 | assertTrue(IppAttribute("some-collection", BegCollection, IppCollection()).isCollection()) 107 | } 108 | 109 | @Test 110 | fun isNotCollection() { 111 | assertFalse(IppAttribute("some-integer", Integer, 0).isCollection()) 112 | } 113 | 114 | @Test 115 | fun attributeToString() { 116 | assertEquals("foo (1setOf integer) = 1,2,3", IppAttribute("foo", Integer, 1, 2, 3).toString()) 117 | } 118 | 119 | @Test 120 | fun attributeWithDateTimeHasValidValueClass() { 121 | IppAttribute("datetime-now", DateTime, IppDateTime.now()).run { 122 | assertTrue(tag.valueHasValidClass(value)) 123 | } 124 | } 125 | 126 | @Test 127 | fun keywordValues() { 128 | assertEquals(listOf("none"), attribute.getKeywordsOrNames()) 129 | } 130 | 131 | @Test 132 | fun nameValue() { 133 | IppAttribute("name", NameWithoutLanguage, IppString("mike")).run { 134 | assertEquals("mike", getKeywordOrName()) 135 | } 136 | } 137 | 138 | @Test 139 | fun exceptionOnStringOrIppString() { 140 | assertFailsWith { 141 | IppAttribute.getStringOrIppStringText(0) 142 | } 143 | } 144 | 145 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/core/IppDateTime.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.core 2 | 3 | /** 4 | * Copyright (c) 2020-2023 Gerhard Muth 5 | */ 6 | 7 | import java.time.LocalDateTime 8 | import java.time.ZoneId 9 | import java.time.ZoneOffset 10 | import java.time.ZonedDateTime 11 | import java.time.temporal.ChronoField 12 | import java.util.* 13 | import kotlin.math.absoluteValue 14 | 15 | // RFC2579 16 | data class IppDateTime( 17 | val year: Int, 18 | val month: Int, 19 | val day: Int, 20 | val hour: Int, 21 | val minutes: Int, 22 | val seconds: Int, 23 | val deciSeconds: Int, 24 | val directionFromUTC: Char, // '+' or '-' 25 | val hoursFromUTC: Int, 26 | val minutesFromUTC: Int 27 | ) { 28 | 29 | override fun toString() = toISO8601() 30 | 31 | fun toRFC2579() = format("%d-%d-%d,%d:%d:%d.%d,%c%d:%d") 32 | fun toISO8601() = format("%04d-%02d-%02dT%02d:%02d:%02d.%01d%c%02d:%02d") 33 | 34 | private fun format(format: String) = 35 | format.format( 36 | year, 37 | month, 38 | day, 39 | hour, 40 | minutes, 41 | seconds, 42 | deciSeconds, 43 | directionFromUTC, 44 | hoursFromUTC, 45 | minutesFromUTC 46 | ) 47 | 48 | // support for java.time.ZonedDateTime 49 | 50 | constructor(zonedDateTime: ZonedDateTime) : this( 51 | year = zonedDateTime.year, 52 | month = zonedDateTime.monthValue, 53 | day = zonedDateTime.dayOfMonth, 54 | hour = zonedDateTime.hour, 55 | minutes = zonedDateTime.minute, 56 | seconds = zonedDateTime.second, 57 | deciSeconds = zonedDateTime[ChronoField.MILLI_OF_SECOND] / 100, 58 | offsetMinutes = zonedDateTime.zone.rules.getOffset(zonedDateTime.toLocalDateTime()).totalSeconds / 60 59 | ) 60 | 61 | fun toZonedDateTime(): ZonedDateTime = 62 | ZonedDateTime.of( 63 | LocalDateTime.of( 64 | year, 65 | month, 66 | day, 67 | hour, 68 | minutes, 69 | seconds, 70 | deciSeconds * 100 * 1000 * 1000 // nanoSeconds 71 | ), 72 | ZoneOffset.ofTotalSeconds(getOffsetMinutes() * 60) 73 | ) 74 | 75 | // support for java.util.Calendar 76 | 77 | constructor(calendar: Calendar) : this( 78 | calendar[Calendar.YEAR], 79 | calendar[Calendar.MONTH] + 1, 80 | calendar[Calendar.DAY_OF_MONTH], 81 | calendar[Calendar.HOUR_OF_DAY], 82 | calendar[Calendar.MINUTE], 83 | calendar[Calendar.SECOND], 84 | deciSeconds = calendar[Calendar.MILLISECOND] / 100, 85 | offsetMinutes = calendar.dstSavingsOffsetMillis() / 1000 / 60 86 | ) 87 | 88 | fun toCalendar(): Calendar = 89 | Calendar.getInstance().apply { 90 | set(Calendar.YEAR, year) 91 | set(Calendar.MONTH, month - 1) 92 | set(Calendar.DAY_OF_MONTH, day) 93 | set(Calendar.HOUR_OF_DAY, hour) 94 | set(Calendar.MINUTE, minutes) 95 | set(Calendar.SECOND, seconds) 96 | set(Calendar.MILLISECOND, deciSeconds * 100) 97 | timeZone = TimeZone.getTimeZone(getTimeZoneId()) 98 | } 99 | 100 | // support for java.util.Date 101 | 102 | constructor(date: Date) : 103 | this(Calendar.getInstance().apply { 104 | time = date 105 | timeZone = TimeZone.getTimeZone("UTC") 106 | }) 107 | 108 | fun toDate(): Date = 109 | toCalendar().time 110 | 111 | // support for offset minutes with direction 112 | 113 | private constructor( 114 | year: Int, 115 | month: Int, 116 | day: Int, 117 | hour: Int, 118 | minutes: Int, 119 | seconds: Int, 120 | deciSeconds: Int, 121 | offsetMinutes: Int 122 | ) : this( 123 | year, 124 | month, 125 | day, 126 | hour, 127 | minutes, 128 | seconds, 129 | deciSeconds, 130 | directionFromUTC = if (offsetMinutes < 0) '-' else '+', 131 | hoursFromUTC = offsetMinutes.absoluteValue / 60, 132 | minutesFromUTC = offsetMinutes.absoluteValue % 60 133 | ) 134 | 135 | internal fun getOffsetMinutes() = 136 | (if (directionFromUTC == '-') -1 else 1) * (hoursFromUTC * 60 + minutesFromUTC) 137 | 138 | internal fun getTimeZoneId() = 139 | "GMT%c%02d%02d".format(directionFromUTC, hoursFromUTC, minutesFromUTC) 140 | 141 | companion object { 142 | fun now(zone: ZoneId = ZoneId.systemDefault()) = 143 | IppDateTime(ZonedDateTime.now(zone)) 144 | 145 | // Calendar extension 146 | private fun Calendar.dstSavingsOffsetMillis() = 147 | with(timeZone) { rawOffset + if (useDaylightTime() && inDaylightTime(time)) dstSavings else 0 } 148 | } 149 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/ipp/core/IppTagTests.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.core 2 | 3 | /** 4 | * Copyright (c) 2020-2023 Gerhard Muth 5 | */ 6 | 7 | import kotlin.test.* 8 | import de.gmuth.ipp.core.IppTag.* 9 | import java.net.URI 10 | 11 | class IppTagTests { 12 | 13 | @Test 14 | fun validInteger() { 15 | assertFalse(Integer.valueHasValidClass("no-integer")) 16 | assertTrue(Integer.valueHasValidClass(1)) 17 | } 18 | 19 | @Test 20 | fun validBoolean() { 21 | assertFalse(IppTag.Boolean.valueHasValidClass("no-boolean")) 22 | assertTrue(IppTag.Boolean.valueHasValidClass(true)) 23 | } 24 | 25 | @Test 26 | fun validEnum() { 27 | assertFalse(IppTag.Enum.valueHasValidClass("no-enum")) 28 | assertTrue(IppTag.Enum.valueHasValidClass(1)) 29 | } 30 | 31 | @Test 32 | fun validOctetString() { 33 | assertFalse(OctetString.valueHasValidClass(0)) 34 | assertTrue(OctetString.valueHasValidClass("string")) 35 | } 36 | 37 | @Test 38 | fun validDateTime() { 39 | assertFalse(DateTime.valueHasValidClass(0)) 40 | assertTrue(DateTime.valueHasValidClass(IppDateTime.now())) 41 | } 42 | 43 | @Test 44 | fun validResolution() { 45 | assertFalse(Resolution.valueHasValidClass(0)) 46 | assertTrue(Resolution.valueHasValidClass(IppResolution(600))) 47 | } 48 | 49 | @Test 50 | fun validRangeOfInteger() { 51 | assertFalse(RangeOfInteger.valueHasValidClass(0)) 52 | assertTrue(RangeOfInteger.valueHasValidClass(0..1)) 53 | } 54 | 55 | @Test 56 | fun validBegCollection() { 57 | assertFalse(BegCollection.valueHasValidClass(0)) 58 | assertTrue(BegCollection.valueHasValidClass(IppCollection())) 59 | } 60 | 61 | @Test 62 | fun validateTextWithLanguage() { 63 | assertFalse(TextWithLanguage.valueHasValidClass(0)) 64 | assertTrue(TextWithLanguage.valueHasValidClass(IppString(""))) 65 | } 66 | 67 | @Test 68 | fun validateNameWithLanguage() { 69 | assertFalse(NameWithLanguage.valueHasValidClass(0)) 70 | assertTrue(NameWithLanguage.valueHasValidClass(IppString(""))) 71 | } 72 | 73 | @Test 74 | fun validEndCollection() { 75 | assertTrue(EndCollection.valueHasValidClass(0)) 76 | assertTrue(EndCollection.valueHasValidClass("")) 77 | } 78 | 79 | @Test 80 | fun validateTextWithoutLanguage() { 81 | assertFalse(TextWithoutLanguage.valueHasValidClass(0)) 82 | assertTrue(TextWithoutLanguage.valueHasValidClass("string")) 83 | assertTrue(TextWithoutLanguage.valueHasValidClass(IppString("ipp-string"))) 84 | } 85 | 86 | @Test 87 | fun validateNameWithoutLanguage() { 88 | assertFalse(NameWithoutLanguage.valueHasValidClass(0)) 89 | assertTrue(NameWithoutLanguage.valueHasValidClass("string")) 90 | assertTrue(NameWithoutLanguage.valueHasValidClass(IppString("ipp-string"))) 91 | } 92 | 93 | private fun validString(tag: IppTag) = tag.run { 94 | assertFalse(valueHasValidClass(0)) 95 | assertTrue(valueHasValidClass("string")) 96 | } 97 | 98 | @Test 99 | fun validKeyword() = 100 | validString(Keyword) 101 | 102 | @Test 103 | fun validUri() { 104 | assertFalse(Uri.valueHasValidClass(0)) 105 | assertTrue(Uri.valueHasValidClass(URI.create("ipp://0"))) 106 | } 107 | 108 | @Test 109 | fun validUriScheme() = 110 | validString(UriScheme) 111 | 112 | @Test 113 | fun validCharset() { 114 | assertFalse(Charset.valueHasValidClass(0)) 115 | assertTrue(Charset.valueHasValidClass(java.nio.charset.Charset.defaultCharset())) 116 | } 117 | 118 | @Test 119 | fun validNaturalLanguage() = 120 | validString(NaturalLanguage) 121 | 122 | @Test 123 | fun validMimeMediaType() = 124 | validString(MimeMediaType) 125 | 126 | @Test 127 | fun validMemberAttrName() = 128 | validString(MemberAttrName) 129 | 130 | @Test 131 | fun tagClassification() { 132 | assertFalse(Printer.isOutOfBandTag()) 133 | assertFalse(Printer.isMemberAttrValue()) 134 | assertFalse(MemberAttrName.isMemberAttrValue()) 135 | } 136 | 137 | @Test 138 | fun registeredSyntax() { 139 | assertEquals("name", NameWithoutLanguage.registeredSyntax()) 140 | assertEquals("text", TextWithoutLanguage.registeredSyntax()) 141 | assertEquals("keyword", Keyword.registeredSyntax()) 142 | } 143 | 144 | @Test 145 | fun fromString() { 146 | assertEquals(Uri, IppTag.fromString("uri")) 147 | } 148 | 149 | @Test 150 | fun fromStringFails() { 151 | assertFailsWith { IppTag.fromString("invalid-tag-name") } 152 | } 153 | 154 | @Test 155 | fun fromByteFails() { 156 | assertFailsWith { IppTag.fromByte(0x77) } 157 | } 158 | 159 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/ipp/core/IppAttributesGroupTests.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.core 2 | 3 | /** 4 | * Copyright (c) 2020-2024 Gerhard Muth 5 | */ 6 | 7 | import de.gmuth.ipp.core.IppAttributesGroup.Companion.replaceEnabled 8 | import de.gmuth.ipp.core.IppTag.* 9 | import java.io.File 10 | import java.io.PrintWriter 11 | import java.time.ZoneId 12 | import java.util.logging.Logger.getLogger 13 | import kotlin.test.* 14 | 15 | class IppAttributesGroupTests { 16 | 17 | private val logger = getLogger(javaClass.name) 18 | private val group = IppAttributesGroup(Operation) 19 | 20 | @Test 21 | fun constructorFails1() { 22 | assertFailsWith { IppAttributesGroup(End) } 23 | } 24 | 25 | @Test 26 | fun constructorFails2() { 27 | assertFailsWith { IppAttributesGroup(Integer) } 28 | } 29 | 30 | @Test 31 | fun putWithReplacementEnabled() { 32 | group.run { 33 | replaceEnabled = true 34 | attribute("number", Integer, 0) 35 | attribute("number", Integer, 1, 2) 36 | assertEquals(1, size) 37 | assertEquals(get("number")!!.values.size, 2) 38 | } 39 | } 40 | 41 | @Test 42 | fun putWithReplacementDisabled() { 43 | group.run { 44 | replaceEnabled = false 45 | put(IppAttribute("number", Integer, 0)) 46 | put(IppAttribute("number", Integer, 1, 2)) 47 | assertEquals(1, size) 48 | assertEquals(get("number")!!.values.size, 1) 49 | } 50 | } 51 | 52 | @Test 53 | fun putEmptyValues() { 54 | group.attribute("empty", Integer, listOf()) 55 | assertEquals(1, group.size) 56 | } 57 | 58 | @Test 59 | fun putAttributesGroup() { 60 | val fooGroup = IppAttributesGroup(Operation).apply { 61 | attribute("one", Integer, 1) 62 | attribute("two", Integer, 2) 63 | } 64 | group.put(fooGroup) 65 | assertEquals(2, group.size) 66 | } 67 | 68 | @Test 69 | fun toStringValue() { 70 | assertEquals("operation group (0 attributes)", group.toString()) 71 | } 72 | 73 | @Test 74 | fun getValue() { 75 | group.attribute("foo", Keyword, "bar") 76 | assertEquals("bar", group.getValue("foo")) 77 | } 78 | 79 | @Test 80 | fun getKeywordOrNameValue() { 81 | group.attribute("foo", NameWithoutLanguage, IppString("bar")) 82 | assertEquals("bar", group.getKeywordOrName("foo")) 83 | } 84 | 85 | @Test 86 | fun getEpochTimeValue() { 87 | group.attribute("epoch-seconds", Integer, 62) 88 | assertEquals( 89 | "1970-01-01T00:01:02", 90 | group.getValueAsZonedDateTime("epoch-seconds") 91 | .withZoneSameInstant(ZoneId.of("UTC")) 92 | .toLocalDateTime().toString() 93 | ) 94 | } 95 | 96 | @Test 97 | fun getValueOrNull() { 98 | group.attribute("foo0", Keyword, "bar0") 99 | assertEquals("bar0", group.getValueOrNull("foo0")) 100 | assertEquals(null, group.getValueOrNull("invalid-name")) 101 | } 102 | 103 | @Test 104 | fun getValues() { 105 | group.attribute("multiple", Integer, 1, 2) 106 | assertEquals(listOf(1, 2), group.getValues("multiple")) 107 | } 108 | 109 | @Test 110 | fun getValuesOrNull() { 111 | group.attribute("multiple0", Integer, 0, 1, 2) 112 | assertEquals(listOf(0, 1, 2), group.getValuesOrNull("multiple0")) 113 | assertEquals(null, group.getValuesOrNull>("invalid-name")) 114 | } 115 | 116 | @Test 117 | fun getValueFails() { 118 | assertFailsWith { group.getValue("invalid-name") } 119 | } 120 | 121 | @Test 122 | fun getValuesFails() { 123 | assertFailsWith { group.getValues("invalid-name") } 124 | } 125 | 126 | @Test 127 | fun log() { 128 | group.attribute("Commodore PET", Integer, 2001) 129 | group.log(logger, prefix = "|", title = "title") 130 | } 131 | 132 | @Test 133 | fun saveAttributes() { 134 | group.attribute("Commodore C", Integer, 64) 135 | group.saveText(File.createTempFile("tempfiles", ".tmp")) 136 | } 137 | 138 | @Test 139 | fun writeTextWithoutTitle() { 140 | group.writeText(PrintWriter(java.lang.System.out), null) 141 | } 142 | 143 | // ------------- interface Map methods ------------ 144 | 145 | @Test 146 | fun mapInterfaceMethods() { 147 | group.remove("a") 148 | 149 | group.attribute("b", Unknown) 150 | assertTrue(group.containsKey("b")) 151 | 152 | val c = IppAttribute("c", Unknown) 153 | group.put(c) 154 | assertTrue(group.containsValue(c)) 155 | 156 | group.getOrDefault("d", IppAttribute("default", Unknown)) 157 | 158 | group.remove("b") 159 | group.remove("c", c) 160 | 161 | assertTrue(group.entries.size == 0) 162 | } 163 | 164 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/gmuth/ipp/core/IppOperation.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.core 2 | 3 | /** 4 | * Copyright (c) 2020-2024 Gerhard Muth 5 | */ 6 | 7 | import java.util.logging.Logger.getLogger 8 | 9 | // https://www.iana.org/assignments/ipp-registrations/ipp-registrations.xml#ipp-registrations-6 10 | enum class IppOperation(val code: Int) { 11 | 12 | // RFC 8011 13 | PrintJob(0x0002), 14 | PrintURI(0x0003), // https://ftp.pwg.org/pub/pwg/ipp/registrations/reg-ippdepuri10-20211215.pdf 15 | ValidateJob(0x0004), 16 | CreateJob(0x0005), 17 | SendDocument(0x0006), 18 | SendURI(0x0007), // https://ftp.pwg.org/pub/pwg/ipp/registrations/reg-ippdepuri10-20211215.pdf 19 | CancelJob(0x0008), 20 | GetJobAttributes(0x0009), 21 | GetJobs(0x000A), 22 | GetPrinterAttributes(0x000B), 23 | HoldJob(0x000C), 24 | ReleaseJob(0x000D), 25 | RestartJob(0x000E), 26 | PausePrinter(0x0010), 27 | ResumePrinter(0x0011), 28 | PurgeJobs(0x0012), 29 | SetPrinterAttributes(0x0013), 30 | SetJobAttributes(0x0014), 31 | GetPrinterSupportedValues(0x0015), 32 | CreatePrinterSubscriptions(0x0016), 33 | CreateJobSubscriptions(0x0017), 34 | GetSubscriptionAttributes(0x0018), 35 | GetSubscriptions(0x0019), 36 | RenewSubscription(0x001A), 37 | CancelSubscription(0x001B), 38 | GetNotifications(0x001C), 39 | GetResourceAttributes(0x001E), 40 | GetResources(0x0020), 41 | 42 | // RFC 3998 43 | EnablePrinter(0x0022), 44 | DisablePrinter(0x0023), 45 | PausePrinterAfterCurrentJob(0x0024), 46 | HoldNewJobs(0x0025), 47 | ReleaseHeldNewJobs(0x0026), 48 | DeactivatePrinter(0x0027), 49 | ActivatePrinter(0x0028), 50 | RestartPrinter(0x0029), 51 | ShutdownPrinter(0x002A), 52 | StartupPrinter(0x002B), 53 | ReprocessJob(0x002C), 54 | CancelCurrentJob(0x002D), 55 | SuspendCurrentJob(0x002E), 56 | ResumeJob(0x002F), 57 | PromoteJob(0x0030), 58 | ScheduleJobAfter(0x0031), 59 | 60 | CancelDocument(0x0033), 61 | GetDocumentAttributes(0x0034), 62 | GetDocuments(0x0035), 63 | DeleteDocument(0x0036), 64 | SetDocumentAttributes(0x0037), 65 | CancelJobs(0x0038), 66 | CancelMyJobs(0x0039), 67 | CloseJob(0x003A), 68 | ResubmitJob(0x003B), 69 | IdentifyPrinter(0x003C), 70 | ValidateDocument(0x003D), 71 | AddDocumentImages(0x003E), 72 | 73 | // PWG 5100.18 Infra 74 | AcknowledgeDocument(0x003F), 75 | AcknowledgeIdentifyPrinter(0x0040), 76 | AcknowledgeJob(0x0041), 77 | FetchDocument(0x0042), 78 | FetchJob(0x0043), 79 | GetOutputDeviceAttributes(0x0044), 80 | UpdateActiveJobs(0x0045), 81 | DeregisterOutputDevice(0x0046), 82 | UpdateDocumentStatus(0x0047), 83 | UpdateJobStatus(0x0048), 84 | UpdateOutputDeviceAttributes(0x0049), 85 | 86 | // PWG 5100.22 System Service 87 | GetNextDocumentData(0x004A), 88 | AllocatePrinterResources(0x004B), 89 | CreatePrinter(0x004C), 90 | DeallocatePrinterResources(0x004D), 91 | DeletePrinter(0x004E), 92 | GetPrinters(0x004F), 93 | ShutdownOnePrinter(0x0050), 94 | StartupOnePrinter(0x0051), 95 | CancelResource(0x0052), 96 | CreateResource(0x0053), 97 | InstallResource(0x0054), 98 | SendResourceData(0x0055), 99 | SetResourceAttributes(0x0056), 100 | CreateResourceSubscriptions(0x0057), 101 | CreateSystemSubscriptions(0x0058), 102 | DisableAllPrinters(0x0059), 103 | EnableAllPrinters(0x005A), 104 | GetSystemAttributes(0x005B), 105 | GetSystemSupportedValues(0x005C), 106 | PauseAllPrinters(0x005D), 107 | PauseAllPrintersAfterCurrentJob(0x005E), 108 | RegisterOutputDevice(0x005F), 109 | RestartSystem(0x0060), 110 | ResumeAllPrinters(0x0061), 111 | SetSystemAttributes(0x0062), 112 | ShutdownAllPrinters(0x0063), 113 | StartupAllPrinters(0x0064), 114 | GetPrinterResources(0x0065), 115 | GetUserPrinterAttributes(0x0066), 116 | RestartOnePrinter(0x0067), 117 | 118 | // CUPS Operations 119 | CupsGetDefault(0x4001), 120 | CupsGetPrinters(0x4002), 121 | CupsAddModifyPrinter(0x4003), 122 | CupsDeletePrinter(0x4004), 123 | CupsGetClasses(0x4005), 124 | CupsAddModifyClass(0x4006), 125 | CupsDeleteClass(0x4007), 126 | CupsAcceptJobs(0x4008), 127 | CupsRejectJobs(0x4009), 128 | CupsSetDefault(0x400A), 129 | CupsGetDevices(0x400B), 130 | CupsGetPPDs(0x400C), 131 | CupsMoveJob(0x400D), 132 | CupsAuthenticateJob(0x400E), 133 | CupsGetPPD(0x400F), 134 | CupsGetDocument(0x4027), 135 | CupsCreateLocalPrinter(0x4028), 136 | 137 | // Unknown 138 | Unknown(-1); 139 | 140 | override fun toString(): String = registeredName() 141 | 142 | fun registeredName() = name 143 | .replace(Regex("[A-Z]+")) { "-" + it.value } 144 | .replace(Regex("^-"), "") 145 | 146 | companion object { 147 | val logger = getLogger(IppOperation::class.java.name) 148 | fun fromInt(code: Int): IppOperation = values() 149 | .find { it.code == code } 150 | ?: Unknown.also { logger.warning("Unknown operation code %04x".format(code)) } 151 | } 152 | 153 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/ipp/iana/CSVReader.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.iana 2 | 3 | /** 4 | * Copyright (c) 2023 Gerhard Muth 5 | */ 6 | 7 | import java.io.InputStream 8 | import java.io.OutputStream 9 | import java.io.PrintWriter 10 | import java.util.logging.Logger.getLogger 11 | import kotlin.math.log10 12 | 13 | // https://tools.ietf.org/html/rfc4180 14 | 15 | class CSVReader(private val rowMapper: RowMapper) { 16 | 17 | interface RowMapper { 18 | fun mapRow(columns: List, rowNum: Int): T 19 | } 20 | 21 | val log = getLogger(javaClass.name) 22 | 23 | fun readResource(resource: String, skipHeader: Boolean = true): List { 24 | return read(javaClass.getResourceAsStream(resource), skipHeader) 25 | } 26 | 27 | fun read(inputStream: InputStream, skipHeader: Boolean = true): List { 28 | val mappedRows = mutableListOf() 29 | if (skipHeader) parseRow(inputStream) 30 | var rowNum = 0 31 | lineLoop@ while (true) { 32 | val columns = parseRow(inputStream) ?: break@lineLoop 33 | val row = rowMapper.mapRow(columns, ++rowNum) 34 | mappedRows.add(row) 35 | } 36 | log.fine { "rows read: ${mappedRows.size}" } 37 | return mappedRows 38 | } 39 | 40 | private fun parseRow(inputStream: InputStream): List? { 41 | val fields = mutableListOf() 42 | var currentField = StringBuffer() 43 | var inQuote = false 44 | var lastCharacterWasQuote = false 45 | columnLoop@ while (true) { 46 | val i = inputStream.read() 47 | if (i == -1) break@columnLoop 48 | val char = i.toChar() 49 | var appendCharacter = false 50 | if (inQuote) { 51 | appendCharacter = char != '"' 52 | } else { 53 | when (char) { 54 | ',' -> { 55 | fields.add(currentField.toString()) 56 | currentField = StringBuffer() 57 | } 58 | '\n' -> { 59 | fields.add(currentField.toString()) 60 | return fields 61 | } 62 | '"' -> { 63 | appendCharacter = lastCharacterWasQuote 64 | } 65 | else -> { 66 | appendCharacter = char != '\r' 67 | } 68 | } 69 | } 70 | lastCharacterWasQuote = char == '"' 71 | if (lastCharacterWasQuote) inQuote = !inQuote 72 | if (appendCharacter) currentField.append(char) 73 | } 74 | if (currentField.isEmpty()) return null 75 | fields.add(currentField.toString()) 76 | return fields 77 | } 78 | 79 | // --- Utility for pretty printing --- 80 | 81 | companion object { 82 | 83 | fun prettyPrintResource(resource: String) { 84 | val rows = readRowsFromResource(resource) 85 | prettyPrint(rows) 86 | } 87 | 88 | fun readRowsFromResource(resource: String): List> { 89 | return readRowsFromInputStream(CSVReader::class.java.getResourceAsStream(resource)) 90 | } 91 | 92 | fun readRowsFromInputStream(inputStream: InputStream): List> { 93 | val csvReader = CSVReader( 94 | object : RowMapper> { 95 | override fun mapRow(columns: List, rowNum: Int): List = columns 96 | } 97 | ) 98 | return csvReader.read(inputStream, false) 99 | } 100 | 101 | fun prettyPrint( 102 | rows: List>, 103 | withRowNumber: Boolean = true, 104 | delimiter: Char = '|', 105 | outputStream: OutputStream = System.out 106 | ) { 107 | // iterate over all fields and find the max column widths 108 | val maxLengthMap = mutableMapOf() 109 | for (row in rows) { 110 | for ((columnNum, column) in row.withIndex()) { 111 | with(maxLengthMap[columnNum]) { 112 | if (this == null || this < column.length) maxLengthMap[columnNum] = column.length 113 | } 114 | } 115 | } 116 | fun maxLength(column: Int) = maxLengthMap[column] ?: throw IllegalArgumentException("column $column not found") 117 | 118 | // iterate over all fields and layout with max column widths 119 | val printWriter = PrintWriter(outputStream, true) 120 | val linesColumnLength: Int = (log10((rows).size.toDouble()) + 1).toInt() 121 | for ((rowNo, columns) in (rows).withIndex()) { 122 | val line = StringBuffer() 123 | if (withRowNumber) line.append(String.format("#%0${linesColumnLength}d%c", rowNo + 1, delimiter)) 124 | else line.append(delimiter) 125 | for ((columnNum, column) in columns.withIndex()) { 126 | line.append(String.format("%-${maxLength(columnNum)}s%c", column, delimiter)) 127 | } 128 | printWriter.println(line.toString()) 129 | } 130 | } 131 | 132 | } 133 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/gmuth/ipp/core/IppDateTimeTests.kt: -------------------------------------------------------------------------------- 1 | package de.gmuth.ipp.core 2 | 3 | /** 4 | * Copyright (c) 2020-2023 Gerhard Muth 5 | */ 6 | 7 | import java.text.SimpleDateFormat 8 | import java.time.ZonedDateTime 9 | import java.util.* 10 | import kotlin.test.Test 11 | import kotlin.test.assertEquals 12 | 13 | class IppDateTimeTests { 14 | 15 | private val javaDateUtc = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX").parse("2020-12-13T10:22:33.400+00:00") 16 | private val ippDateTime3HoursEast = IppDateTime(2020, 12, 13, 13, 22, 33, 4, '+', 3, 0) 17 | private val ippDateTime1HourWest = IppDateTime(2020, 12, 13, 9, 22, 33, 4, '-', 1, 0) 18 | 19 | @Test 20 | fun toRFC2579() { 21 | assertEquals("2020-12-13,13:22:33.4,+3:0", ippDateTime3HoursEast.toRFC2579()) 22 | } 23 | 24 | @Test 25 | fun toString_ISO8601() { 26 | assertEquals("2020-12-13T13:22:33.4+03:00", ippDateTime3HoursEast.toString()) 27 | } 28 | 29 | @Test 30 | fun toZonedDateTimeEast() { 31 | assertEquals("2020-12-13T13:22:33.400+03:00", ippDateTime3HoursEast.toZonedDateTime().toString()) 32 | } 33 | 34 | @Test 35 | fun toZonedDateTimeWest() { 36 | assertEquals("2020-12-13T09:22:33.400-01:00", ippDateTime1HourWest.toZonedDateTime().toString()) 37 | } 38 | 39 | @Test 40 | fun zonedDateTimeConstructorEast() { 41 | assertEquals(ippDateTime3HoursEast, IppDateTime(ZonedDateTime.parse("2020-12-13T13:22:33.400+03:00"))) 42 | } 43 | 44 | @Test 45 | fun zonedDateTimeConstructorWest() { 46 | assertEquals(ippDateTime1HourWest, IppDateTime(ZonedDateTime.parse("2020-12-13T09:22:33.400-01:00"))) 47 | } 48 | 49 | @Test 50 | fun toCalendarEast() { 51 | ippDateTime3HoursEast.toCalendar().run { 52 | assertEquals(2020, get(Calendar.YEAR)) 53 | assertEquals(12 - 1, get(Calendar.MONTH)) 54 | assertEquals(13, get(Calendar.DAY_OF_MONTH)) 55 | assertEquals(13, get(Calendar.HOUR_OF_DAY)) 56 | assertEquals(22, get(Calendar.MINUTE)) 57 | assertEquals(33, get(Calendar.SECOND)) 58 | assertEquals(400, get(Calendar.MILLISECOND)) 59 | assertEquals("GMT+03:00", timeZone.id) 60 | } 61 | } 62 | 63 | @Test 64 | fun toCalendarWest() { 65 | ippDateTime1HourWest.toCalendar().run { 66 | assertEquals(2020, get(Calendar.YEAR)) 67 | assertEquals(12 - 1, get(Calendar.MONTH)) 68 | assertEquals(13, get(Calendar.DAY_OF_MONTH)) 69 | assertEquals(9, get(Calendar.HOUR_OF_DAY)) 70 | assertEquals(22, get(Calendar.MINUTE)) 71 | assertEquals(33, get(Calendar.SECOND)) 72 | assertEquals(400, get(Calendar.MILLISECOND)) 73 | assertEquals("GMT-01:00", timeZone.id) 74 | } 75 | } 76 | 77 | @Test 78 | fun calendarWestConstructor() { 79 | val calendar = Calendar.getInstance(TimeZone.getTimeZone("GMT-01:00")).apply { 80 | set(Calendar.YEAR, 2020) 81 | set(Calendar.MONTH, 12 - 1) 82 | set(Calendar.DAY_OF_MONTH, 13) 83 | set(Calendar.HOUR_OF_DAY, 9) 84 | set(Calendar.MINUTE, 22) 85 | set(Calendar.SECOND, 33) 86 | set(Calendar.MILLISECOND, 400) 87 | } 88 | assertEquals(ippDateTime1HourWest, IppDateTime(calendar)) 89 | } 90 | 91 | @Test 92 | fun calendarConstructorWithDaylightSaving() { 93 | val calendar = Calendar.getInstance(TimeZone.getTimeZone("Europe/Berlin")).apply { 94 | set(Calendar.YEAR, 2020) 95 | set(Calendar.MONTH, 6 - 1) 96 | set(Calendar.DAY_OF_MONTH, 13) 97 | set(Calendar.HOUR_OF_DAY, 12) 98 | set(Calendar.MINUTE, 22) 99 | set(Calendar.SECOND, 33) 100 | set(Calendar.MILLISECOND, 400) 101 | } 102 | assertEquals(12, IppDateTime(calendar).hour) 103 | } 104 | 105 | @Test 106 | fun calendarConstructorWithoutDaylightSaving() { 107 | val calendar = Calendar.getInstance(TimeZone.getTimeZone("Europe/Berlin")).apply { 108 | set(Calendar.YEAR, 2020) 109 | set(Calendar.MONTH, 12 - 1) 110 | set(Calendar.DAY_OF_MONTH, 13) 111 | set(Calendar.HOUR_OF_DAY, 12) 112 | set(Calendar.MINUTE, 22) 113 | set(Calendar.SECOND, 33) 114 | set(Calendar.MILLISECOND, 400) 115 | } 116 | assertEquals(12, IppDateTime(calendar).hour) 117 | } 118 | 119 | @Test 120 | fun toDate() { 121 | GregorianCalendar(TimeZone.getTimeZone("UTC")).run { 122 | time = ippDateTime3HoursEast.toDate() 123 | assertEquals(2020, get(Calendar.YEAR)) 124 | assertEquals(12 - 1, get(Calendar.MONTH)) 125 | assertEquals(13, get(Calendar.DAY_OF_MONTH)) 126 | assertEquals(10, get(Calendar.HOUR_OF_DAY)) 127 | assertEquals(22, get(Calendar.MINUTE)) 128 | assertEquals(33, get(Calendar.SECOND)) 129 | assertEquals(400, get(Calendar.MILLISECOND)) 130 | assertEquals("UTC", timeZone.id) 131 | } 132 | } 133 | 134 | @Test 135 | fun dateConstructor() { 136 | IppDateTime(javaDateUtc).run { 137 | assertEquals(2020, year) 138 | assertEquals(12, month) 139 | assertEquals(13, day) 140 | assertEquals(10, hour) 141 | assertEquals(22, minutes) 142 | assertEquals(33, seconds) 143 | assertEquals(4, deciSeconds) 144 | } 145 | } 146 | 147 | } --------------------------------------------------------------------------------