From 05e1f94c79b7f1ffdbd49dcb217c9172d6f80d5e Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 9 Jan 2026 07:14:39 +0000 Subject: [PATCH] initial --- package.json | 14 +- pnpm-lock.yaml | 295 ++++++++ test/test.ts | 191 ++++- ts/abstract/device.abstract.ts | 202 +++++ ts/devicemanager.classes.devicemanager.ts | 477 ++++++++++++ ts/discovery/discovery.classes.mdns.ts | 313 ++++++++ .../discovery.classes.networkscanner.ts | 378 ++++++++++ ts/discovery/discovery.classes.ssdp.ts | 357 +++++++++ ts/helpers/helpers.iprange.ts | 171 +++++ ts/helpers/helpers.retry.ts | 100 +++ ts/index.ts | 37 +- ts/interfaces/index.ts | 366 +++++++++ ts/plugins.ts | 29 +- ts/printer/printer.classes.ippprotocol.ts | 329 +++++++++ ts/printer/printer.classes.printer.ts | 255 +++++++ ts/scanner/scanner.classes.esclprotocol.ts | 423 +++++++++++ ts/scanner/scanner.classes.saneprotocol.ts | 694 ++++++++++++++++++ ts/scanner/scanner.classes.scanner.ts | 370 ++++++++++ ts/snmp/snmp.classes.snmpdevice.ts | 271 +++++++ ts/snmp/snmp.classes.snmpprotocol.ts | 439 +++++++++++ ts/ups/ups.classes.nutprotocol.ts | 471 ++++++++++++ ts/ups/ups.classes.upssnmp.ts | 377 ++++++++++ 22 files changed, 6549 insertions(+), 10 deletions(-) create mode 100644 ts/abstract/device.abstract.ts create mode 100644 ts/devicemanager.classes.devicemanager.ts create mode 100644 ts/discovery/discovery.classes.mdns.ts create mode 100644 ts/discovery/discovery.classes.networkscanner.ts create mode 100644 ts/discovery/discovery.classes.ssdp.ts create mode 100644 ts/helpers/helpers.iprange.ts create mode 100644 ts/helpers/helpers.retry.ts create mode 100644 ts/interfaces/index.ts create mode 100644 ts/printer/printer.classes.ippprotocol.ts create mode 100644 ts/printer/printer.classes.printer.ts create mode 100644 ts/scanner/scanner.classes.esclprotocol.ts create mode 100644 ts/scanner/scanner.classes.saneprotocol.ts create mode 100644 ts/scanner/scanner.classes.scanner.ts create mode 100644 ts/snmp/snmp.classes.snmpdevice.ts create mode 100644 ts/snmp/snmp.classes.snmpprotocol.ts create mode 100644 ts/ups/ups.classes.nutprotocol.ts create mode 100644 ts/ups/ups.classes.upssnmp.ts diff --git a/package.json b/package.json index 94108d4..460a80a 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,18 @@ "@types/node": "^25.0.3" }, "dependencies": { - "@push.rocks/smartpath": "^6.0.0" + "@push.rocks/smartdelay": "^3.0.5", + "@push.rocks/smartevent": "^2.0.5", + "@push.rocks/smartnetwork": "^4.4.0", + "@push.rocks/smartpath": "^6.0.0", + "@push.rocks/smartpromise": "^4.2.3", + "@push.rocks/smartrequest": "^5.0.1", + "@push.rocks/smartxml": "^2.0.0", + "bonjour-service": "^1.3.0", + "castv2-client": "^1.2.0", + "ipp": "^2.0.1", + "net-snmp": "^3.26.0", + "node-ssdp": "^4.0.1", + "sonos": "^1.14.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4d40e5..a372417 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,45 @@ importers: .: dependencies: + '@push.rocks/smartdelay': + specifier: ^3.0.5 + version: 3.0.5 + '@push.rocks/smartevent': + specifier: ^2.0.5 + version: 2.0.5 + '@push.rocks/smartnetwork': + specifier: ^4.4.0 + version: 4.4.0 '@push.rocks/smartpath': specifier: ^6.0.0 version: 6.0.0 + '@push.rocks/smartpromise': + specifier: ^4.2.3 + version: 4.2.3 + '@push.rocks/smartrequest': + specifier: ^5.0.1 + version: 5.0.1 + '@push.rocks/smartxml': + specifier: ^2.0.0 + version: 2.0.0 + bonjour-service: + specifier: ^1.3.0 + version: 1.3.0 + castv2-client: + specifier: ^1.2.0 + version: 1.2.0 + ipp: + specifier: ^2.0.1 + version: 2.0.1 + net-snmp: + specifier: ^3.26.0 + version: 3.26.0 + node-ssdp: + specifier: ^4.0.1 + version: 4.0.1 + sonos: + specifier: ^1.14.2 + version: 1.14.2 devDependencies: '@git.zone/tsbuild': specifier: ^4.1.0 @@ -535,6 +571,36 @@ packages: resolution: {integrity: sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==} engines: {node: '>=12'} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@puppeteer/browsers@2.11.0': resolution: {integrity: sha512-n6oQX6mYkG8TRPuPXmbPidkUbsSRalhmaaVAQxvH1IkQy63cwsH+kOjB3e4cpCDHg0aSvsiX9bQ4s2VB6mGWUQ==} engines: {node: '>=18'} @@ -616,6 +682,9 @@ packages: '@push.rocks/smarterror@2.0.1': resolution: {integrity: sha512-iCcH1D8tlDJgMFsaJ6lhdOTKhbU0KoprNv9MRP9o7691QOx4JEDXiHtr/lNtxVo8BUtdb9CF6kazaknO9KuORA==} + '@push.rocks/smartevent@2.0.5': + resolution: {integrity: sha512-aU1hEoiMv8qDs+b3ln6e6GseyqM8sSqkGxhNTteLM6ve5dmTofnAdQ/tXshYNUUg2kPqi4ohcuf1/iACwjXNHw==} + '@push.rocks/smartexit@1.1.0': resolution: {integrity: sha512-GD8VLIbxQuwvhPXwK4eH162XAYSj+M3wGKWGNO3i1iY4bj8P3BARcgsWx6/ntN3aCo5ygWtrevrfD5iecYY2Ng==} @@ -812,6 +881,10 @@ packages: resolution: {integrity: sha512-bqorOaGXPOuiOSV81luTKrTghg4O4NBRD0zyv7TIqmrMGf4a0uoozaUMp1X8vQdZW+y0gTzUJP9wkzAE6Cci0g==} deprecated: This package has been deprecated in favour of the new package at @push.rocks/smartpromise + '@pushrocks/smartrx@2.0.27': + resolution: {integrity: sha512-aFRpGxDZgHH1mpmkRBTFwuIVqFiDxk22n2vX2gW4hntV0nJGlt9M9dixMFFXGUjabwX9hHW7y60QPJm2rKaypA==} + deprecated: This package has been deprecated in favour of the new package at @push.rocks/smartrx + '@pushrocks/smartstring@4.0.7': resolution: {integrity: sha512-TxHSar7Cj29E+GOcIj4DeZKWCNVzHKdqnrBRqcBqLqmeYZvzFosLXpFKoaCJDq7MSxuPoCvu5woSdp9YmPXyog==} deprecated: This package has been deprecated in favour of the new package at @push.rocks/smartstring @@ -1289,6 +1362,9 @@ packages: '@types/jsonfile@6.1.4': resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} + '@types/long@4.0.2': + resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -1419,6 +1495,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + asn1-ber@1.2.2: + resolution: {integrity: sha512-CbNem/7hxrjSiOAOOTX4iZxu+0m3jiLqlsERQwwPM1IDR/22M8IPpA1VVndCLw5KtjRYyRODbvAEIfuTogNDng==} + asn1js@3.0.7: resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==} engines: {node: '>=12.0.0'} @@ -1430,6 +1509,9 @@ packages: async-mutex@0.5.0: resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} + async@2.6.4: + resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==} + asynckit@0.4.0: resolution: {integrity: sha1-x57Zf380y48robyXkLzDZkdLS3k=} @@ -1499,6 +1581,9 @@ packages: resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==} engines: {node: '>=10.0.0'} + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + bn.js@4.12.2: resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==} @@ -1506,6 +1591,9 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} + bonjour-service@1.3.0: + resolution: {integrity: sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==} + bowser@2.13.1: resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} @@ -1569,6 +1657,12 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} + castv2-client@1.2.0: + resolution: {integrity: sha512-2diOsC0vSSxa3QEOgoGBy9fZRHzNXatHz464Kje2OpwQ7GM5vulyrD0gLFOQ1P4rgLAFsYiSGQl4gK402nEEuA==} + + castv2@0.1.10: + resolution: {integrity: sha512-3QWevHrjT22KdF08Y2a217IYCDQDP7vEJaY4n0lPBeC5UBYbMFMadDfVTsaQwq7wqsEgYUHElPGm3EO1ey+TNw==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1679,6 +1773,22 @@ packages: dayjs@1.11.19: resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.3.7: resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} engines: {node: '>=6.0'} @@ -2137,10 +2247,17 @@ packages: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} + ip@1.1.9: + resolution: {integrity: sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + ipp@2.0.1: + resolution: {integrity: sha512-p5dO2BXAVDnkv6IhUBupwydkq5/uw+DE+MGXnYzziNK1AtuLgbT9dFfJ3f8pA+J21n43TYipm6et/hTDEFJU/A==} + engines: {node: <4.0.0} + is-arrayish@0.2.1: resolution: {integrity: sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=} @@ -2284,6 +2401,12 @@ packages: lodash.restparam@3.6.1: resolution: {integrity: sha1-k2pOMJ7zMKdkXtQUWYbIWuWyCAU=} + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + long@4.0.0: + resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -2557,9 +2680,16 @@ packages: socks: optional: true + ms@2.0.0: + resolution: {integrity: sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + multicast-dns@7.2.5: + resolution: {integrity: sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==} + hasBin: true + nanoid@4.0.2: resolution: {integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==} engines: {node: ^14 || ^16 || >=18} @@ -2573,6 +2703,9 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + net-snmp@3.26.0: + resolution: {integrity: sha512-sjL3tRHjSRHFfExXeY1kXwFcwlZnmGAJOMWK6MQT/7MMnJZaCM/n/U/03gLM1zOdUKI1+xUwlDAX/F4m8v86AA==} + netmask@2.0.2: resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} engines: {node: '>= 0.4.0'} @@ -2588,6 +2721,10 @@ packages: resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==} engines: {node: '>= 6.13.0'} + node-ssdp@4.0.1: + resolution: {integrity: sha512-uJXkLZVuyaMg1qNbMbGQ6YzNzyOD+NLxYyxIJocPTKTVECPDokOiCZA686jTLXHMUnV34uY/lcUSJ+/5fhY43A==} + engines: {node: '>=0.10.0'} + normalize-newline@4.1.0: resolution: {integrity: sha512-ff4jKqMI8Xl50/4Mms/9jPobzAV/UK+kXG2XJ/7AqOmxIx8mqfqTIHYxuAnEgJ2AQeBbLnlbmZ5+38Y9A0w/YA==} engines: {node: '>=12'} @@ -2755,6 +2892,10 @@ packages: proto-list@1.2.4: resolution: {integrity: sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=} + protobufjs@6.11.4: + resolution: {integrity: sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==} + hasBin: true + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -2898,6 +3039,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sax@1.4.4: + resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} + engines: {node: '>=11.0.0'} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -2980,6 +3125,9 @@ packages: resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sonos@1.14.2: + resolution: {integrity: sha512-E2haOiusny1mgfZvZxXCKOlnvrzoxdnTFXKhcVKPkpWGN1FYzjHUt9UZxQHzflnt48eVKpwGhX6d6miniNBfSQ==} + source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -3075,6 +3223,9 @@ packages: through2@4.0.2: resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} + thunky@1.1.0: + resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==} + tiny-worker@2.3.0: resolution: {integrity: sha512-pJ70wq5EAqTAEl9IkGzA+fN0836rycEuz2Cn6yeZ6FRzlVS5IDOkFHpIoEsksPRQV34GDqXm65+OlnZqUSyK2g==} @@ -3278,6 +3429,14 @@ packages: utf-8-validate: optional: true + xml2js@0.5.0: + resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + xmlhttprequest-ssl@2.1.2: resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} engines: {node: '>=0.4.0'} @@ -4345,6 +4504,29 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@puppeteer/browsers@2.11.0': dependencies: debug: 4.4.3 @@ -4622,6 +4804,11 @@ snapshots: clean-stack: 1.3.0 make-error-cause: 2.3.0 + '@push.rocks/smartevent@2.0.5': + dependencies: + '@pushrocks/smartpromise': 3.1.10 + '@pushrocks/smartrx': 2.0.27 + '@push.rocks/smartexit@1.1.0': dependencies: '@push.rocks/lik': 6.2.2 @@ -5118,6 +5305,11 @@ snapshots: '@pushrocks/smartpromise@4.0.2': {} + '@pushrocks/smartrx@2.0.27': + dependencies: + '@pushrocks/smartpromise': 3.1.10 + rxjs: 7.8.2 + '@pushrocks/smartstring@4.0.7': dependencies: '@pushrocks/isounique': 1.0.5 @@ -5682,6 +5874,8 @@ snapshots: dependencies: '@types/node': 25.0.3 + '@types/long@4.0.2': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -5806,6 +6000,8 @@ snapshots: argparse@2.0.1: {} + asn1-ber@1.2.2: {} + asn1js@3.0.7: dependencies: pvtsutils: 1.3.6 @@ -5820,6 +6016,10 @@ snapshots: dependencies: tslib: 2.8.1 + async@2.6.4: + dependencies: + lodash: 4.17.21 + asynckit@0.4.0: {} axios@1.13.2(debug@4.4.3): @@ -5879,6 +6079,8 @@ snapshots: basic-ftp@5.1.0: {} + bluebird@3.7.2: {} + bn.js@4.12.2: {} body-parser@2.2.2: @@ -5895,6 +6097,11 @@ snapshots: transitivePeerDependencies: - supports-color + bonjour-service@1.3.0: + dependencies: + fast-deep-equal: 3.1.3 + multicast-dns: 7.2.5 + bowser@2.13.1: {} brace-expansion@1.1.12: @@ -5966,6 +6173,20 @@ snapshots: camelcase@6.3.0: {} + castv2-client@1.2.0: + dependencies: + castv2: 0.1.10 + debug: 2.6.9 + transitivePeerDependencies: + - supports-color + + castv2@0.1.10: + dependencies: + debug: 4.4.3 + protobufjs: 6.11.4 + transitivePeerDependencies: + - supports-color + ccount@2.0.1: {} character-entities-html4@2.1.0: {} @@ -6059,6 +6280,14 @@ snapshots: dayjs@1.11.19: {} + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + debug@4.3.7: dependencies: ms: 2.1.3 @@ -6630,8 +6859,12 @@ snapshots: ip-address@10.1.0: {} + ip@1.1.9: {} + ipaddr.js@1.9.1: {} + ipp@2.0.1: {} + is-arrayish@0.2.1: {} is-docker@2.2.1: {} @@ -6761,6 +6994,10 @@ snapshots: lodash.restparam@3.6.1: {} + lodash@4.17.21: {} + + long@4.0.0: {} + longest-streak@3.1.0: {} lower-case@1.1.4: {} @@ -7215,14 +7452,26 @@ snapshots: optionalDependencies: socks: 2.8.7 + ms@2.0.0: {} + ms@2.1.3: {} + multicast-dns@7.2.5: + dependencies: + dns-packet: 5.6.1 + thunky: 1.1.0 + nanoid@4.0.2: {} negotiator@0.6.3: {} negotiator@1.0.0: {} + net-snmp@3.26.0: + dependencies: + asn1-ber: 1.2.2 + smart-buffer: 4.2.0 + netmask@2.0.2: {} new-find-package-json@2.0.0: @@ -7237,6 +7486,16 @@ snapshots: node-forge@1.3.3: {} + node-ssdp@4.0.1: + dependencies: + async: 2.6.4 + bluebird: 3.7.2 + debug: 3.2.7 + extend: 3.0.2 + ip: 1.1.9 + transitivePeerDependencies: + - supports-color + normalize-newline@4.1.0: dependencies: replace-buffer: 1.2.1 @@ -7384,6 +7643,22 @@ snapshots: proto-list@1.2.4: {} + protobufjs@6.11.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/long': 4.0.2 + '@types/node': 25.0.3 + long: 4.0.0 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -7600,6 +7875,8 @@ snapshots: safer-buffer@2.1.2: {} + sax@1.4.4: {} + semver@6.3.1: {} semver@7.7.3: {} @@ -7734,6 +8011,15 @@ snapshots: ip-address: 10.1.0 smart-buffer: 4.2.0 + sonos@1.14.2: + dependencies: + axios: 1.13.2(debug@4.4.3) + debug: 4.4.3 + ip: 1.1.9 + xml2js: 0.5.0 + transitivePeerDependencies: + - supports-color + source-map@0.6.1: {} space-separated-tokens@2.0.2: {} @@ -7858,6 +8144,8 @@ snapshots: dependencies: readable-stream: 3.6.2 + thunky@1.1.0: {} + tiny-worker@2.3.0: dependencies: esm: 3.2.25 @@ -8022,6 +8310,13 @@ snapshots: ws@8.19.0: {} + xml2js@0.5.0: + dependencies: + sax: 1.4.4 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + xmlhttprequest-ssl@2.1.2: {} y18n@5.0.8: {} diff --git a/test/test.ts b/test/test.ts index dd8a1a1..64b0122 100644 --- a/test/test.ts +++ b/test/test.ts @@ -1,8 +1,189 @@ import { expect, tap } from '@git.zone/tstest/tapbundle'; -import * as devicemanager from '../ts/index.js' +import * as devicemanager from '../ts/index.js'; -tap.test('first test', async () => { - console.log(devicemanager) -}) +// Test imports +tap.test('should export DeviceManager', async () => { + expect(devicemanager.DeviceManager).toBeDefined(); + expect(typeof devicemanager.DeviceManager).toEqual('function'); +}); -export default tap.start() +tap.test('should export Scanner', async () => { + expect(devicemanager.Scanner).toBeDefined(); + expect(typeof devicemanager.Scanner).toEqual('function'); +}); + +tap.test('should export Printer', async () => { + expect(devicemanager.Printer).toBeDefined(); + expect(typeof devicemanager.Printer).toEqual('function'); +}); + +tap.test('should export protocol implementations', async () => { + expect(devicemanager.EsclProtocol).toBeDefined(); + expect(devicemanager.SaneProtocol).toBeDefined(); + expect(devicemanager.IppProtocol).toBeDefined(); +}); + +tap.test('should export retry helpers', async () => { + expect(devicemanager.withRetry).toBeDefined(); + expect(devicemanager.createRetryable).toBeDefined(); + expect(devicemanager.defaultRetryOptions).toBeDefined(); +}); + +// Test DeviceManager creation +tap.test('should create DeviceManager instance', async () => { + const dm = new devicemanager.DeviceManager({ + autoDiscovery: false, + }); + + expect(dm).toBeInstanceOf(devicemanager.DeviceManager); + expect(dm.isDiscovering).toEqual(false); + expect(dm.getScanners()).toEqual([]); + expect(dm.getPrinters()).toEqual([]); +}); + +// Test retry helper +tap.test('withRetry should succeed on first try', async () => { + let callCount = 0; + const result = await devicemanager.withRetry(async () => { + callCount++; + return 'success'; + }); + + expect(result).toEqual('success'); + expect(callCount).toEqual(1); +}); + +tap.test('withRetry should retry on failure', async () => { + let callCount = 0; + const result = await devicemanager.withRetry( + async () => { + callCount++; + if (callCount < 3) { + throw new Error('Temporary failure'); + } + return 'success after retries'; + }, + { maxRetries: 5, baseDelay: 10, maxDelay: 100 } + ); + + expect(result).toEqual('success after retries'); + expect(callCount).toEqual(3); +}); + +tap.test('withRetry should throw after max retries', async () => { + let callCount = 0; + let error: Error | null = null; + + try { + await devicemanager.withRetry( + async () => { + callCount++; + throw new Error('Persistent failure'); + }, + { maxRetries: 2, baseDelay: 10, maxDelay: 100 } + ); + } catch (e) { + error = e as Error; + } + + expect(error).not.toBeNull(); + expect(error?.message).toEqual('Persistent failure'); + expect(callCount).toEqual(3); // Initial + 2 retries +}); + +// Test discovery (non-blocking) +tap.test('should start and stop discovery', async () => { + const dm = new devicemanager.DeviceManager(); + + // Track events + let discoveryStarted = false; + let discoveryStopped = false; + + dm.on('discovery:started', () => { + discoveryStarted = true; + }); + + dm.on('discovery:stopped', () => { + discoveryStopped = true; + }); + + await dm.startDiscovery(); + expect(dm.isDiscovering).toEqual(true); + expect(discoveryStarted).toEqual(true); + + // Wait a bit for potential device discovery + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Log discovered devices + const scanners = dm.getScanners(); + const printers = dm.getPrinters(); + console.log(`Discovered ${scanners.length} scanner(s) and ${printers.length} printer(s)`); + + for (const scanner of scanners) { + console.log(` Scanner: ${scanner.name} (${scanner.address}:${scanner.port}) - ${scanner.protocol}`); + } + + for (const printer of printers) { + console.log(` Printer: ${printer.name} (${printer.address}:${printer.port})`); + } + + await dm.stopDiscovery(); + expect(dm.isDiscovering).toEqual(false); + expect(discoveryStopped).toEqual(true); + + await dm.shutdown(); +}); + +// Test Scanner creation from discovery info +tap.test('should create Scanner from discovery info', async () => { + const scanner = devicemanager.Scanner.fromDiscovery({ + id: 'test:scanner:1', + name: 'Test Scanner', + address: '192.168.1.100', + port: 443, + protocol: 'escl', + txtRecords: { + 'ty': 'HP LaserJet MFP', + 'pdl': 'image/jpeg,application/pdf', + 'cs': 'color,grayscale', + 'is': 'platen,adf', + }, + }); + + expect(scanner.name).toEqual('Test Scanner'); + expect(scanner.address).toEqual('192.168.1.100'); + expect(scanner.port).toEqual(443); + expect(scanner.protocol).toEqual('escl'); + expect(scanner.supportedFormats).toContain('jpeg'); + expect(scanner.supportedFormats).toContain('pdf'); + expect(scanner.supportedColorModes).toContain('color'); + expect(scanner.supportedColorModes).toContain('grayscale'); + expect(scanner.supportedSources).toContain('flatbed'); + expect(scanner.supportedSources).toContain('adf'); + expect(scanner.hasAdf).toEqual(true); +}); + +// Test Printer creation from discovery info +tap.test('should create Printer from discovery info', async () => { + const printer = devicemanager.Printer.fromDiscovery({ + id: 'test:printer:1', + name: 'Test Printer', + address: '192.168.1.101', + port: 631, + txtRecords: { + 'ty': 'Brother HL-L2350DW', + 'rp': 'ipp/print', + 'Color': 'T', + 'Duplex': 'T', + }, + }); + + expect(printer.name).toEqual('Test Printer'); + expect(printer.address).toEqual('192.168.1.101'); + expect(printer.port).toEqual(631); + expect(printer.supportsColor).toEqual(true); + expect(printer.supportsDuplex).toEqual(true); + expect(printer.uri).toContain('ipp://'); +}); + +export default tap.start(); diff --git a/ts/abstract/device.abstract.ts b/ts/abstract/device.abstract.ts new file mode 100644 index 0000000..477280b --- /dev/null +++ b/ts/abstract/device.abstract.ts @@ -0,0 +1,202 @@ +import * as plugins from '../plugins.js'; +import type { + IDeviceInfo, + TDeviceType, + TDeviceStatus, + TConnectionState, + IRetryOptions, +} from '../interfaces/index.js'; +import { withRetry } from '../helpers/helpers.retry.js'; + +/** + * Abstract base class for all devices (scanners, printers) + */ +export abstract class Device extends plugins.events.EventEmitter { + public readonly id: string; + public readonly name: string; + public readonly type: TDeviceType; + public readonly address: string; + public readonly port: number; + + protected _status: TDeviceStatus = 'unknown'; + protected _connectionState: TConnectionState = 'disconnected'; + protected _lastError: Error | null = null; + + public manufacturer?: string; + public model?: string; + public serialNumber?: string; + public firmwareVersion?: string; + + protected retryOptions: IRetryOptions; + + constructor(info: IDeviceInfo, retryOptions?: IRetryOptions) { + super(); + this.id = info.id; + this.name = info.name; + this.type = info.type; + this.address = info.address; + this.port = info.port; + this._status = info.status; + this.manufacturer = info.manufacturer; + this.model = info.model; + this.serialNumber = info.serialNumber; + this.firmwareVersion = info.firmwareVersion; + + this.retryOptions = retryOptions ?? { + maxRetries: 5, + baseDelay: 1000, + maxDelay: 16000, + multiplier: 2, + jitter: true, + }; + } + + /** + * Get current device status + */ + public get status(): TDeviceStatus { + return this._status; + } + + /** + * Get current connection state + */ + public get connectionState(): TConnectionState { + return this._connectionState; + } + + /** + * Get last error if any + */ + public get lastError(): Error | null { + return this._lastError; + } + + /** + * Check if device is connected + */ + public get isConnected(): boolean { + return this._connectionState === 'connected'; + } + + /** + * Update device status + */ + protected setStatus(status: TDeviceStatus): void { + if (this._status !== status) { + const oldStatus = this._status; + this._status = status; + this.emit('status:changed', { oldStatus, newStatus: status }); + } + } + + /** + * Update connection state + */ + protected setConnectionState(state: TConnectionState): void { + if (this._connectionState !== state) { + const oldState = this._connectionState; + this._connectionState = state; + this.emit('connection:changed', { oldState, newState: state }); + } + } + + /** + * Set error state + */ + protected setError(error: Error): void { + this._lastError = error; + this.setStatus('error'); + this.emit('error', error); + } + + /** + * Clear error state + */ + protected clearError(): void { + this._lastError = null; + if (this._status === 'error') { + this.setStatus('online'); + } + } + + /** + * Execute an operation with retry logic + */ + protected async withRetry(fn: () => Promise): Promise { + return withRetry(fn, this.retryOptions); + } + + /** + * Connect to the device + */ + public async connect(): Promise { + if (this.isConnected) { + return; + } + + this.setConnectionState('connecting'); + this.clearError(); + + try { + await this.withRetry(() => this.doConnect()); + this.setConnectionState('connected'); + this.setStatus('online'); + } catch (error) { + this.setConnectionState('error'); + this.setError(error instanceof Error ? error : new Error(String(error))); + throw error; + } + } + + /** + * Disconnect from the device + */ + public async disconnect(): Promise { + if (this._connectionState === 'disconnected') { + return; + } + + try { + await this.doDisconnect(); + } finally { + this.setConnectionState('disconnected'); + } + } + + /** + * Get device info as plain object + */ + public getInfo(): IDeviceInfo { + return { + id: this.id, + name: this.name, + type: this.type, + address: this.address, + port: this.port, + status: this._status, + manufacturer: this.manufacturer, + model: this.model, + serialNumber: this.serialNumber, + firmwareVersion: this.firmwareVersion, + }; + } + + /** + * Implementation-specific connect logic + * Override in subclasses + */ + protected abstract doConnect(): Promise; + + /** + * Implementation-specific disconnect logic + * Override in subclasses + */ + protected abstract doDisconnect(): Promise; + + /** + * Refresh device status + * Override in subclasses + */ + public abstract refreshStatus(): Promise; +} diff --git a/ts/devicemanager.classes.devicemanager.ts b/ts/devicemanager.classes.devicemanager.ts new file mode 100644 index 0000000..e845344 --- /dev/null +++ b/ts/devicemanager.classes.devicemanager.ts @@ -0,0 +1,477 @@ +import * as plugins from './plugins.js'; +import { MdnsDiscovery, SERVICE_TYPES } from './discovery/discovery.classes.mdns.js'; +import { NetworkScanner } from './discovery/discovery.classes.networkscanner.js'; +import { Scanner } from './scanner/scanner.classes.scanner.js'; +import { Printer } from './printer/printer.classes.printer.js'; +import type { + IDeviceManagerOptions, + IDiscoveredDevice, + IRetryOptions, + TDeviceManagerEvents, + INetworkScanOptions, + INetworkScanResult, +} from './interfaces/index.js'; + +/** + * Default device manager options + */ +const DEFAULT_OPTIONS: Required = { + autoDiscovery: true, + discoveryTimeout: 10000, + enableRetry: true, + maxRetries: 5, + retryBaseDelay: 1000, +}; + +/** + * Main Device Manager class for discovering and managing network devices + */ +export class DeviceManager extends plugins.events.EventEmitter { + private discovery: MdnsDiscovery; + private _networkScanner: NetworkScanner | null = null; + private scanners: Map = new Map(); + private printers: Map = new Map(); + private options: Required; + private retryOptions: IRetryOptions; + + constructor(options?: IDeviceManagerOptions) { + super(); + this.options = { ...DEFAULT_OPTIONS, ...options }; + + this.retryOptions = { + maxRetries: this.options.maxRetries, + baseDelay: this.options.retryBaseDelay, + maxDelay: 16000, + multiplier: 2, + jitter: true, + }; + + this.discovery = new MdnsDiscovery({ + timeout: this.options.discoveryTimeout, + }); + + this.setupDiscoveryEvents(); + } + + /** + * Setup event forwarding from discovery service + */ + private setupDiscoveryEvents(): void { + this.discovery.on('device:found', (device: IDiscoveredDevice) => { + this.handleDeviceFound(device); + }); + + this.discovery.on('device:lost', (device: IDiscoveredDevice) => { + this.handleDeviceLost(device); + }); + + this.discovery.on('scanner:found', (device: IDiscoveredDevice) => { + // Scanner found event is emitted after device:found handling + }); + + this.discovery.on('printer:found', (device: IDiscoveredDevice) => { + // Printer found event is emitted after device:found handling + }); + + this.discovery.on('started', () => { + this.emit('discovery:started'); + }); + + this.discovery.on('stopped', () => { + this.emit('discovery:stopped'); + }); + } + + /** + * Handle newly discovered device + */ + private handleDeviceFound(device: IDiscoveredDevice): void { + if (device.type === 'scanner') { + // Create Scanner instance + const scanner = Scanner.fromDiscovery( + { + id: device.id, + name: device.name, + address: device.address, + port: device.port, + protocol: device.protocol as 'sane' | 'escl', + txtRecords: device.txtRecords, + }, + this.options.enableRetry ? this.retryOptions : undefined + ); + + this.scanners.set(device.id, scanner); + this.emit('scanner:found', scanner.getScannerInfo()); + } else if (device.type === 'printer') { + // Create Printer instance + const printer = Printer.fromDiscovery( + { + id: device.id, + name: device.name, + address: device.address, + port: device.port, + txtRecords: device.txtRecords, + }, + this.options.enableRetry ? this.retryOptions : undefined + ); + + this.printers.set(device.id, printer); + this.emit('printer:found', printer.getPrinterInfo()); + } + } + + /** + * Handle lost device + */ + private handleDeviceLost(device: IDiscoveredDevice): void { + if (device.type === 'scanner') { + const scanner = this.scanners.get(device.id); + if (scanner) { + // Disconnect if connected + if (scanner.isConnected) { + scanner.disconnect().catch(() => {}); + } + this.scanners.delete(device.id); + this.emit('scanner:lost', device.id); + } + } else if (device.type === 'printer') { + const printer = this.printers.get(device.id); + if (printer) { + // Disconnect if connected + if (printer.isConnected) { + printer.disconnect().catch(() => {}); + } + this.printers.delete(device.id); + this.emit('printer:lost', device.id); + } + } + } + + /** + * Start device discovery + */ + public async startDiscovery(): Promise { + await this.discovery.start(); + } + + /** + * Stop device discovery + */ + public async stopDiscovery(): Promise { + await this.discovery.stop(); + } + + /** + * Check if discovery is running + */ + public get isDiscovering(): boolean { + return this.discovery.running; + } + + /** + * Get all discovered scanners + */ + public getScanners(): Scanner[] { + return Array.from(this.scanners.values()); + } + + /** + * Get all discovered printers + */ + public getPrinters(): Printer[] { + return Array.from(this.printers.values()); + } + + /** + * Get scanner by ID + */ + public getScanner(id: string): Scanner | undefined { + return this.scanners.get(id); + } + + /** + * Get printer by ID + */ + public getPrinter(id: string): Printer | undefined { + return this.printers.get(id); + } + + /** + * Get all devices (scanners and printers) + */ + public getDevices(): (Scanner | Printer)[] { + return [...this.getScanners(), ...this.getPrinters()]; + } + + /** + * Get device by ID (scanner or printer) + */ + public getDevice(id: string): Scanner | Printer | undefined { + return this.scanners.get(id) ?? this.printers.get(id); + } + + /** + * Add a scanner manually (without discovery) + */ + public async addScanner( + address: string, + port: number, + protocol: 'escl' | 'sane' = 'escl', + name?: string + ): Promise { + const id = `manual:${protocol}:${address}:${port}`; + + // Check if already exists + if (this.scanners.has(id)) { + return this.scanners.get(id)!; + } + + const scanner = Scanner.fromDiscovery( + { + id, + name: name ?? `Scanner at ${address}`, + address, + port, + protocol, + txtRecords: {}, + }, + this.options.enableRetry ? this.retryOptions : undefined + ); + + // Try to connect to validate + await scanner.connect(); + + this.scanners.set(id, scanner); + this.emit('scanner:found', scanner.getScannerInfo()); + + return scanner; + } + + /** + * Add a printer manually (without discovery) + */ + public async addPrinter( + address: string, + port: number = 631, + name?: string, + ippPath?: string + ): Promise { + const id = `manual:ipp:${address}:${port}`; + + // Check if already exists + if (this.printers.has(id)) { + return this.printers.get(id)!; + } + + const printer = Printer.fromDiscovery( + { + id, + name: name ?? `Printer at ${address}`, + address, + port, + txtRecords: ippPath ? { rp: ippPath } : {}, + }, + this.options.enableRetry ? this.retryOptions : undefined + ); + + // Try to connect to validate + await printer.connect(); + + this.printers.set(id, printer); + this.emit('printer:found', printer.getPrinterInfo()); + + return printer; + } + + /** + * Get the NetworkScanner instance for advanced control + */ + public get networkScanner(): NetworkScanner { + if (!this._networkScanner) { + this._networkScanner = new NetworkScanner(); + this.setupNetworkScannerEvents(); + } + return this._networkScanner; + } + + /** + * Setup event forwarding from network scanner + */ + private setupNetworkScannerEvents(): void { + if (!this._networkScanner) return; + + this._networkScanner.on('device:found', (result: INetworkScanResult) => { + this.emit('network:device:found', result); + }); + + this._networkScanner.on('progress', (progress) => { + this.emit('network:progress', progress); + }); + + this._networkScanner.on('complete', (results) => { + this.emit('network:complete', results); + }); + + this._networkScanner.on('error', (error) => { + this.emit('error', error); + }); + } + + /** + * Scan a network range for devices (IP-based, not mDNS) + * Found devices are automatically added to the device manager + */ + public async scanNetwork( + options: INetworkScanOptions + ): Promise<{ scanners: Scanner[]; printers: Printer[] }> { + const results = await this.networkScanner.scan(options); + const foundScanners: Scanner[] = []; + const foundPrinters: Printer[] = []; + + for (const result of results) { + for (const device of result.devices) { + try { + if (device.type === 'scanner') { + if (device.protocol === 'escl') { + const scanner = await this.addScanner( + result.address, + device.port, + 'escl', + device.name + ); + foundScanners.push(scanner); + } else if (device.protocol === 'sane') { + const scanner = await this.addScanner( + result.address, + device.port, + 'sane', + device.name + ); + foundScanners.push(scanner); + } + } else if (device.type === 'printer') { + if (device.protocol === 'ipp') { + const printer = await this.addPrinter( + result.address, + device.port, + device.name + ); + foundPrinters.push(printer); + } + // JetDirect printers don't have a protocol handler yet + } + } catch (error) { + // Device could not be added (connection failed, etc.) + this.emit('error', error instanceof Error ? error : new Error(String(error))); + } + } + } + + return { scanners: foundScanners, printers: foundPrinters }; + } + + /** + * Cancel an ongoing network scan + */ + public async cancelNetworkScan(): Promise { + if (this._networkScanner) { + await this._networkScanner.cancel(); + } + } + + /** + * Check if a network scan is in progress + */ + public get isNetworkScanning(): boolean { + return this._networkScanner?.isScanning ?? false; + } + + /** + * Remove a device + */ + public async removeDevice(id: string): Promise { + const scanner = this.scanners.get(id); + if (scanner) { + if (scanner.isConnected) { + await scanner.disconnect(); + } + this.scanners.delete(id); + this.emit('scanner:lost', id); + return true; + } + + const printer = this.printers.get(id); + if (printer) { + if (printer.isConnected) { + await printer.disconnect(); + } + this.printers.delete(id); + this.emit('printer:lost', id); + return true; + } + + return false; + } + + /** + * Disconnect all devices + */ + public async disconnectAll(): Promise { + const disconnectPromises: Promise[] = []; + + for (const scanner of this.scanners.values()) { + if (scanner.isConnected) { + disconnectPromises.push(scanner.disconnect().catch(() => {})); + } + } + + for (const printer of this.printers.values()) { + if (printer.isConnected) { + disconnectPromises.push(printer.disconnect().catch(() => {})); + } + } + + await Promise.all(disconnectPromises); + } + + /** + * Stop discovery and disconnect all devices + */ + public async shutdown(): Promise { + await this.stopDiscovery(); + await this.disconnectAll(); + this.scanners.clear(); + this.printers.clear(); + } + + /** + * Refresh status of all devices + */ + public async refreshAllStatus(): Promise { + const refreshPromises: Promise[] = []; + + for (const scanner of this.scanners.values()) { + if (scanner.isConnected) { + refreshPromises.push( + scanner.refreshStatus().catch((error) => { + this.emit('error', error); + }) + ); + } + } + + for (const printer of this.printers.values()) { + if (printer.isConnected) { + refreshPromises.push( + printer.refreshStatus().catch((error) => { + this.emit('error', error); + }) + ); + } + } + + await Promise.all(refreshPromises); + } +} + +export { MdnsDiscovery, NetworkScanner, Scanner, Printer, SERVICE_TYPES }; diff --git a/ts/discovery/discovery.classes.mdns.ts b/ts/discovery/discovery.classes.mdns.ts new file mode 100644 index 0000000..15d358b --- /dev/null +++ b/ts/discovery/discovery.classes.mdns.ts @@ -0,0 +1,313 @@ +import * as plugins from '../plugins.js'; +import type { + IDiscoveredDevice, + IDiscoveryOptions, + TDeviceType, + TScannerProtocol, +} from '../interfaces/index.js'; + +/** + * Service type definitions for mDNS discovery + */ +const SERVICE_TYPES = { + // Scanners + ESCL: '_uscan._tcp', // eSCL/AirScan scanners + ESCL_SECURE: '_uscans._tcp', // eSCL over TLS + SANE: '_scanner._tcp', // SANE network scanners + + // Printers + IPP: '_ipp._tcp', // IPP printers + IPPS: '_ipps._tcp', // IPP over TLS + PDL: '_pdl-datastream._tcp', // Raw/JetDirect printers +} as const; + +/** + * Default discovery options + */ +const DEFAULT_OPTIONS: Required = { + serviceTypes: [ + SERVICE_TYPES.ESCL, + SERVICE_TYPES.ESCL_SECURE, + SERVICE_TYPES.SANE, + SERVICE_TYPES.IPP, + SERVICE_TYPES.IPPS, + ], + timeout: 10000, +}; + +/** + * mDNS/Bonjour discovery service for network devices + */ +export class MdnsDiscovery extends plugins.events.EventEmitter { + private bonjour: plugins.bonjourService.Bonjour | null = null; + private browsers: plugins.bonjourService.Browser[] = []; + private discoveredDevices: Map = new Map(); + private options: Required; + private isRunning: boolean = false; + + constructor(options?: IDiscoveryOptions) { + super(); + this.options = { ...DEFAULT_OPTIONS, ...options }; + } + + /** + * Check if discovery is currently running + */ + public get running(): boolean { + return this.isRunning; + } + + /** + * Get all discovered devices + */ + public getDevices(): IDiscoveredDevice[] { + return Array.from(this.discoveredDevices.values()); + } + + /** + * Get discovered scanners only + */ + public getScanners(): IDiscoveredDevice[] { + return this.getDevices().filter((d) => d.type === 'scanner'); + } + + /** + * Get discovered printers only + */ + public getPrinters(): IDiscoveredDevice[] { + return this.getDevices().filter((d) => d.type === 'printer'); + } + + /** + * Start mDNS discovery + */ + public async start(): Promise { + if (this.isRunning) { + return; + } + + this.bonjour = new plugins.bonjourService.Bonjour(); + this.isRunning = true; + this.emit('started'); + + for (const serviceType of this.options.serviceTypes) { + this.browseService(serviceType); + } + } + + /** + * Stop mDNS discovery + */ + public async stop(): Promise { + if (!this.isRunning) { + return; + } + + // Stop all browsers + for (const browser of this.browsers) { + browser.stop(); + } + this.browsers = []; + + // Destroy bonjour instance + if (this.bonjour) { + this.bonjour.destroy(); + this.bonjour = null; + } + + this.isRunning = false; + this.emit('stopped'); + } + + /** + * Clear discovered devices + */ + public clear(): void { + this.discoveredDevices.clear(); + } + + /** + * Browse for a specific service type + */ + private browseService(serviceType: string): void { + if (!this.bonjour) { + return; + } + + const browser = this.bonjour.find({ type: serviceType }, (service) => { + this.handleServiceFound(service, serviceType); + }); + + browser.on('down', (service) => { + this.handleServiceLost(service, serviceType); + }); + + this.browsers.push(browser); + } + + /** + * Handle discovered service + */ + private handleServiceFound( + service: plugins.bonjourService.Service, + serviceType: string + ): void { + const device = this.parseService(service, serviceType); + if (!device) { + return; + } + + const existingDevice = this.discoveredDevices.get(device.id); + if (existingDevice) { + // Update existing device + this.discoveredDevices.set(device.id, device); + this.emit('device:updated', device); + } else { + // New device found + this.discoveredDevices.set(device.id, device); + this.emit('device:found', device); + + if (device.type === 'scanner') { + this.emit('scanner:found', device); + } else if (device.type === 'printer') { + this.emit('printer:found', device); + } + } + } + + /** + * Handle lost service + */ + private handleServiceLost( + service: plugins.bonjourService.Service, + serviceType: string + ): void { + const deviceId = this.generateDeviceId(service, serviceType); + const device = this.discoveredDevices.get(deviceId); + + if (device) { + this.discoveredDevices.delete(deviceId); + this.emit('device:lost', device); + + if (device.type === 'scanner') { + this.emit('scanner:lost', deviceId); + } else if (device.type === 'printer') { + this.emit('printer:lost', deviceId); + } + } + } + + /** + * Parse Bonjour service into device info + */ + private parseService( + service: plugins.bonjourService.Service, + serviceType: string + ): IDiscoveredDevice | null { + const addresses = service.addresses ?? []; + // Prefer IPv4 address + const address = + addresses.find((a) => !a.includes(':')) ?? addresses[0] ?? service.host; + + if (!address) { + return null; + } + + const txtRecords = this.parseTxtRecords(service.txt); + const deviceType = this.getDeviceType(serviceType); + const protocol = this.getProtocol(serviceType); + + const deviceId = this.generateDeviceId(service, serviceType); + + return { + id: deviceId, + name: service.name || txtRecords['ty'] || txtRecords['product'] || 'Unknown Device', + type: deviceType, + protocol: protocol, + address: address, + port: service.port, + txtRecords: txtRecords, + serviceType: serviceType, + }; + } + + /** + * Generate unique device ID + */ + private generateDeviceId( + service: plugins.bonjourService.Service, + serviceType: string + ): string { + const addresses = service.addresses ?? []; + const address = addresses.find((a) => !a.includes(':')) ?? addresses[0] ?? service.host; + return `${serviceType}:${address}:${service.port}`; + } + + /** + * Parse TXT records from service + */ + private parseTxtRecords( + txt: Record | undefined + ): Record { + const records: Record = {}; + + if (!txt) { + return records; + } + + for (const [key, value] of Object.entries(txt)) { + if (typeof value === 'string') { + records[key] = value; + } else if (Buffer.isBuffer(value)) { + records[key] = value.toString('utf-8'); + } else if (value !== undefined && value !== null) { + records[key] = String(value); + } + } + + return records; + } + + /** + * Determine device type from service type + */ + private getDeviceType(serviceType: string): TDeviceType { + switch (serviceType) { + case SERVICE_TYPES.ESCL: + case SERVICE_TYPES.ESCL_SECURE: + case SERVICE_TYPES.SANE: + return 'scanner'; + case SERVICE_TYPES.IPP: + case SERVICE_TYPES.IPPS: + case SERVICE_TYPES.PDL: + return 'printer'; + default: + // Check if it's a scanner or printer based on service type pattern + if (serviceType.includes('scan') || serviceType.includes('scanner')) { + return 'scanner'; + } + return 'printer'; + } + } + + /** + * Determine protocol from service type + */ + private getProtocol(serviceType: string): TScannerProtocol | 'ipp' { + switch (serviceType) { + case SERVICE_TYPES.ESCL: + case SERVICE_TYPES.ESCL_SECURE: + return 'escl'; + case SERVICE_TYPES.SANE: + return 'sane'; + case SERVICE_TYPES.IPP: + case SERVICE_TYPES.IPPS: + case SERVICE_TYPES.PDL: + return 'ipp'; + default: + return 'ipp'; + } + } +} + +export { SERVICE_TYPES }; diff --git a/ts/discovery/discovery.classes.networkscanner.ts b/ts/discovery/discovery.classes.networkscanner.ts new file mode 100644 index 0000000..a9d33f4 --- /dev/null +++ b/ts/discovery/discovery.classes.networkscanner.ts @@ -0,0 +1,378 @@ +import * as plugins from '../plugins.js'; +import { EsclProtocol } from '../scanner/scanner.classes.esclprotocol.js'; +import { IppProtocol } from '../printer/printer.classes.ippprotocol.js'; +import { + cidrToIps, + ipRangeToIps, + isValidIp, +} from '../helpers/helpers.iprange.js'; +import type { + INetworkScanOptions, + INetworkScanResult, + INetworkScanDevice, + INetworkScanProgress, +} from '../interfaces/index.js'; + +/** + * Default ports to probe for device discovery + */ +const DEFAULT_PORTS = [631, 80, 443, 6566, 9100]; + +/** + * Default scan options + */ +const DEFAULT_OPTIONS: Required> = { + concurrency: 50, + timeout: 2000, + ports: DEFAULT_PORTS, + probeEscl: true, + probeIpp: true, + probeSane: true, +}; + +/** + * Simple concurrency limiter + */ +class ConcurrencyLimiter { + private running = 0; + private queue: (() => void)[] = []; + + constructor(private limit: number) {} + + async run(fn: () => Promise): Promise { + while (this.running >= this.limit) { + await new Promise((resolve) => this.queue.push(resolve)); + } + + this.running++; + try { + return await fn(); + } finally { + this.running--; + const next = this.queue.shift(); + if (next) next(); + } + } +} + +/** + * Network Scanner - scans IP ranges for scanners and printers + */ +export class NetworkScanner extends plugins.events.EventEmitter { + private cancelled = false; + private scanning = false; + + /** + * Check if a scan is currently in progress + */ + public get isScanning(): boolean { + return this.scanning; + } + + /** + * Scan a network range for devices + */ + public async scan(options: INetworkScanOptions): Promise { + if (this.scanning) { + throw new Error('A scan is already in progress'); + } + + this.cancelled = false; + this.scanning = true; + + const opts = { ...DEFAULT_OPTIONS, ...options }; + const results: INetworkScanResult[] = []; + + try { + // Get list of IPs to scan + const ips = this.resolveIps(options); + + if (ips.length === 0) { + throw new Error('No IPs to scan. Provide ipRange, or startIp and endIp.'); + } + + const limiter = new ConcurrencyLimiter(opts.concurrency); + let scanned = 0; + + // Progress tracking + const emitProgress = (currentIp?: string) => { + const progress: INetworkScanProgress = { + total: ips.length, + scanned, + percentage: Math.round((scanned / ips.length) * 100), + currentIp, + devicesFound: results.reduce((sum, r) => sum + r.devices.length, 0), + }; + this.emit('progress', progress); + }; + + emitProgress(); + + // Scan all IPs with concurrency limit + const scanPromises = ips.map((ip) => + limiter.run(async () => { + if (this.cancelled) return; + + const devices = await this.probeIp(ip, opts); + scanned++; + + if (devices.length > 0) { + const result: INetworkScanResult = { address: ip, devices }; + results.push(result); + this.emit('device:found', result); + } + + emitProgress(ip); + }) + ); + + await Promise.all(scanPromises); + + if (this.cancelled) { + this.emit('cancelled'); + } else { + this.emit('complete', results); + } + + return results; + } finally { + this.scanning = false; + } + } + + /** + * Cancel an ongoing scan + */ + public async cancel(): Promise { + if (this.scanning) { + this.cancelled = true; + } + } + + /** + * Resolve IPs from options (CIDR or start/end range) + */ + private resolveIps(options: INetworkScanOptions): string[] { + if (options.ipRange) { + return cidrToIps(options.ipRange); + } + + if (options.startIp && options.endIp) { + return ipRangeToIps(options.startIp, options.endIp); + } + + if (options.startIp && !options.endIp) { + // Single IP + if (isValidIp(options.startIp)) { + return [options.startIp]; + } + } + + return []; + } + + /** + * Probe a single IP address for devices + */ + private async probeIp( + ip: string, + opts: Required> + ): Promise { + const devices: INetworkScanDevice[] = []; + const timeout = opts.timeout; + + // First, do a quick port scan to see which ports are open + const openPorts = await this.scanPorts(ip, opts.ports, timeout / 2); + + if (openPorts.length === 0 || this.cancelled) { + return devices; + } + + // Probe each open port for specific protocols + const probePromises: Promise[] = []; + + for (const port of openPorts) { + // IPP probe (port 631) + if (opts.probeIpp && port === 631) { + probePromises.push( + this.probeIpp(ip, port, timeout).then((device) => { + if (device) devices.push(device); + }) + ); + } + + // eSCL probe (ports 80, 443) + if (opts.probeEscl && (port === 80 || port === 443)) { + probePromises.push( + this.probeEscl(ip, port, timeout).then((device) => { + if (device) devices.push(device); + }) + ); + } + + // SANE probe (port 6566) + if (opts.probeSane && port === 6566) { + probePromises.push( + this.probeSane(ip, port, timeout).then((device) => { + if (device) devices.push(device); + }) + ); + } + + // JetDirect probe (port 9100) - just mark as raw printer + if (port === 9100) { + devices.push({ + type: 'printer', + protocol: 'jetdirect', + port: 9100, + name: `Raw Printer at ${ip}`, + }); + } + } + + await Promise.all(probePromises); + + return devices; + } + + /** + * Quick TCP port scan + */ + private async scanPorts(ip: string, ports: number[], timeout: number): Promise { + const openPorts: number[] = []; + + const scanPromises = ports.map(async (port) => { + const isOpen = await this.isPortOpen(ip, port, timeout); + if (isOpen) { + openPorts.push(port); + } + }); + + await Promise.all(scanPromises); + return openPorts; + } + + /** + * Check if a TCP port is open + */ + private isPortOpen(ip: string, port: number, timeout: number): Promise { + return new Promise((resolve) => { + const socket = new plugins.net.Socket(); + + const timer = setTimeout(() => { + socket.destroy(); + resolve(false); + }, timeout); + + socket.on('connect', () => { + clearTimeout(timer); + socket.destroy(); + resolve(true); + }); + + socket.on('error', () => { + clearTimeout(timer); + socket.destroy(); + resolve(false); + }); + + socket.connect(port, ip); + }); + } + + /** + * Probe for IPP printer + */ + private async probeIpp( + ip: string, + port: number, + timeout: number + ): Promise { + try { + const ipp = new IppProtocol(ip, port); + const attrs = await Promise.race([ + ipp.getAttributes(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout')), timeout) + ), + ]); + + if (attrs) { + return { + type: 'printer', + protocol: 'ipp', + port, + name: `IPP Printer at ${ip}`, + model: undefined, + }; + } + } catch { + // Not an IPP printer + } + + return null; + } + + /** + * Probe for eSCL scanner + */ + private async probeEscl( + ip: string, + port: number, + timeout: number + ): Promise { + try { + const secure = port === 443; + const escl = new EsclProtocol(ip, port, secure); + + const caps = await Promise.race([ + escl.getCapabilities(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout')), timeout) + ), + ]); + + if (caps) { + return { + type: 'scanner', + protocol: 'escl', + port, + name: caps.makeAndModel || `eSCL Scanner at ${ip}`, + model: caps.makeAndModel, + }; + } + } catch { + // Not an eSCL scanner + } + + return null; + } + + /** + * Probe for SANE scanner (quick check) + */ + private async probeSane( + ip: string, + port: number, + timeout: number + ): Promise { + // SANE probing requires full protocol implementation + // For now, just check if port is open and assume it's SANE + // A more thorough implementation would send SANE_NET_INIT + try { + const isOpen = await this.isPortOpen(ip, port, timeout); + if (isOpen) { + return { + type: 'scanner', + protocol: 'sane', + port, + name: `SANE Scanner at ${ip}`, + }; + } + } catch { + // Not a SANE scanner + } + + return null; + } +} diff --git a/ts/discovery/discovery.classes.ssdp.ts b/ts/discovery/discovery.classes.ssdp.ts new file mode 100644 index 0000000..67f852b --- /dev/null +++ b/ts/discovery/discovery.classes.ssdp.ts @@ -0,0 +1,357 @@ +import * as plugins from '../plugins.js'; + +/** + * SSDP service types for device discovery + */ +export const SSDP_SERVICE_TYPES = { + // Root device + ROOT: 'upnp:rootdevice', + + // DLNA/UPnP AV + MEDIA_RENDERER: 'urn:schemas-upnp-org:device:MediaRenderer:1', + MEDIA_SERVER: 'urn:schemas-upnp-org:device:MediaServer:1', + + // Sonos + SONOS_ZONE_PLAYER: 'urn:schemas-upnp-org:device:ZonePlayer:1', + + // Generic + BASIC_DEVICE: 'urn:schemas-upnp-org:device:Basic:1', + INTERNET_GATEWAY: 'urn:schemas-upnp-org:device:InternetGatewayDevice:1', +}; + +/** + * SSDP discovered device information + */ +export interface ISsdpDevice { + /** Unique service name (USN) */ + usn: string; + /** Service type (ST) */ + serviceType: string; + /** Location URL of device description */ + location: string; + /** IP address */ + address: string; + /** Port number */ + port: number; + /** Device description (fetched from location) */ + description?: ISsdpDeviceDescription; + /** Raw headers from SSDP response */ + headers: Record; +} + +/** + * UPnP device description (from XML) + */ +export interface ISsdpDeviceDescription { + deviceType: string; + friendlyName: string; + manufacturer: string; + manufacturerURL?: string; + modelDescription?: string; + modelName: string; + modelNumber?: string; + modelURL?: string; + serialNumber?: string; + UDN: string; + services: ISsdpService[]; + icons?: ISsdpIcon[]; +} + +export interface ISsdpService { + serviceType: string; + serviceId: string; + SCPDURL: string; + controlURL: string; + eventSubURL: string; +} + +export interface ISsdpIcon { + mimetype: string; + width: number; + height: number; + depth: number; + url: string; +} + +/** + * SSDP Discovery service using node-ssdp + */ +export class SsdpDiscovery extends plugins.events.EventEmitter { + private client: InstanceType | null = null; + private devices: Map = new Map(); + private running = false; + private searchInterval: NodeJS.Timeout | null = null; + + constructor() { + super(); + } + + /** + * Start SSDP discovery + */ + public async start(serviceTypes?: string[]): Promise { + if (this.running) { + return; + } + + this.running = true; + this.client = new plugins.nodeSsdp.Client(); + + // Handle SSDP responses + this.client.on('response', (headers: Record, statusCode: number, rinfo: { address: string; port: number }) => { + this.handleSsdpResponse(headers, rinfo); + }); + + // Search for devices + const typesToSearch = serviceTypes ?? [ + SSDP_SERVICE_TYPES.ROOT, + SSDP_SERVICE_TYPES.MEDIA_RENDERER, + SSDP_SERVICE_TYPES.MEDIA_SERVER, + SSDP_SERVICE_TYPES.SONOS_ZONE_PLAYER, + ]; + + // Initial search + for (const st of typesToSearch) { + this.client.search(st); + } + + // Periodic re-search (every 30 seconds) + this.searchInterval = setInterval(() => { + if (this.client) { + for (const st of typesToSearch) { + this.client.search(st); + } + } + }, 30000); + + this.emit('started'); + } + + /** + * Stop SSDP discovery + */ + public async stop(): Promise { + if (!this.running) { + return; + } + + this.running = false; + + if (this.searchInterval) { + clearInterval(this.searchInterval); + this.searchInterval = null; + } + + if (this.client) { + this.client.stop(); + this.client = null; + } + + this.emit('stopped'); + } + + /** + * Check if discovery is running + */ + public get isRunning(): boolean { + return this.running; + } + + /** + * Get all discovered devices + */ + public getDevices(): ISsdpDevice[] { + return Array.from(this.devices.values()); + } + + /** + * Get devices by service type + */ + public getDevicesByType(serviceType: string): ISsdpDevice[] { + return this.getDevices().filter((d) => d.serviceType === serviceType); + } + + /** + * Search for a specific service type + */ + public search(serviceType: string): void { + if (this.client && this.running) { + this.client.search(serviceType); + } + } + + /** + * Handle SSDP response + */ + private handleSsdpResponse( + headers: Record, + rinfo: { address: string; port: number } + ): void { + const usn = headers['USN'] || headers['usn']; + const location = headers['LOCATION'] || headers['location']; + const st = headers['ST'] || headers['st']; + + if (!usn || !location) { + return; + } + + // Parse location URL + let address = rinfo.address; + let port = 80; + + try { + const url = new URL(location); + address = url.hostname; + port = parseInt(url.port) || (url.protocol === 'https:' ? 443 : 80); + } catch { + // Keep rinfo address + } + + const device: ISsdpDevice = { + usn, + serviceType: st || 'unknown', + location, + address, + port, + headers: { ...headers }, + }; + + const isNew = !this.devices.has(usn); + this.devices.set(usn, device); + + if (isNew) { + // Fetch device description + this.fetchDeviceDescription(device).then(() => { + this.emit('device:found', device); + }).catch(() => { + // Still emit even without description + this.emit('device:found', device); + }); + } else { + this.emit('device:updated', device); + } + } + + /** + * Fetch and parse device description XML + */ + private async fetchDeviceDescription(device: ISsdpDevice): Promise { + try { + const response = await fetch(device.location, { + signal: AbortSignal.timeout(5000), + }); + + if (!response.ok) { + return; + } + + const xml = await response.text(); + device.description = this.parseDeviceDescription(xml); + } catch { + // Ignore fetch errors + } + } + + /** + * Parse UPnP device description XML + */ + private parseDeviceDescription(xml: string): ISsdpDeviceDescription { + const getTagContent = (tag: string, source: string = xml): string => { + const regex = new RegExp(`<${tag}[^>]*>([^<]*)`, 'i'); + const match = source.match(regex); + return match?.[1]?.trim() ?? ''; + }; + + const getTagBlock = (tag: string, source: string = xml): string => { + const regex = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)`, 'i'); + const match = source.match(regex); + return match?.[1] ?? ''; + }; + + // Parse services + const services: ISsdpService[] = []; + const serviceListBlock = getTagBlock('serviceList'); + const serviceMatches = serviceListBlock.match(/[\s\S]*?<\/service>/gi) || []; + + for (const serviceXml of serviceMatches) { + services.push({ + serviceType: getTagContent('serviceType', serviceXml), + serviceId: getTagContent('serviceId', serviceXml), + SCPDURL: getTagContent('SCPDURL', serviceXml), + controlURL: getTagContent('controlURL', serviceXml), + eventSubURL: getTagContent('eventSubURL', serviceXml), + }); + } + + // Parse icons + const icons: ISsdpIcon[] = []; + const iconListBlock = getTagBlock('iconList'); + const iconMatches = iconListBlock.match(/[\s\S]*?<\/icon>/gi) || []; + + for (const iconXml of iconMatches) { + icons.push({ + mimetype: getTagContent('mimetype', iconXml), + width: parseInt(getTagContent('width', iconXml)) || 0, + height: parseInt(getTagContent('height', iconXml)) || 0, + depth: parseInt(getTagContent('depth', iconXml)) || 0, + url: getTagContent('url', iconXml), + }); + } + + return { + deviceType: getTagContent('deviceType'), + friendlyName: getTagContent('friendlyName'), + manufacturer: getTagContent('manufacturer'), + manufacturerURL: getTagContent('manufacturerURL') || undefined, + modelDescription: getTagContent('modelDescription') || undefined, + modelName: getTagContent('modelName'), + modelNumber: getTagContent('modelNumber') || undefined, + modelURL: getTagContent('modelURL') || undefined, + serialNumber: getTagContent('serialNumber') || undefined, + UDN: getTagContent('UDN'), + services, + icons: icons.length > 0 ? icons : undefined, + }; + } + + /** + * Make a UPnP SOAP request + */ + public async soapRequest( + controlUrl: string, + serviceType: string, + action: string, + args: Record = {} + ): Promise { + // Build SOAP body + let argsXml = ''; + for (const [key, value] of Object.entries(args)) { + argsXml += `<${key}>${value}`; + } + + const soapBody = ` + + + + ${argsXml} + + +`; + + const response = await fetch(controlUrl, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + 'SOAPACTION': `"${serviceType}#${action}"`, + }, + body: soapBody, + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) { + throw new Error(`SOAP request failed: ${response.status}`); + } + + return response.text(); + } +} diff --git a/ts/helpers/helpers.iprange.ts b/ts/helpers/helpers.iprange.ts new file mode 100644 index 0000000..0c1715b --- /dev/null +++ b/ts/helpers/helpers.iprange.ts @@ -0,0 +1,171 @@ +/** + * IP Range utility functions for network scanning + */ + +/** + * Validates an IPv4 address + */ +export function isValidIp(ip: string): boolean { + const parts = ip.split('.'); + if (parts.length !== 4) return false; + + return parts.every((part) => { + const num = parseInt(part, 10); + return !isNaN(num) && num >= 0 && num <= 255 && part === num.toString(); + }); +} + +/** + * Converts an IPv4 address to a 32-bit number + */ +export function ipToNumber(ip: string): number { + const parts = ip.split('.').map((p) => parseInt(p, 10)); + return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0; +} + +/** + * Converts a 32-bit number to an IPv4 address + */ +export function numberToIp(num: number): string { + return [ + (num >>> 24) & 255, + (num >>> 16) & 255, + (num >>> 8) & 255, + num & 255, + ].join('.'); +} + +/** + * Generates an array of IP addresses from a start to end range + * Excludes network address (.0) and broadcast address (.255) for /24 ranges + */ +export function ipRangeToIps(startIp: string, endIp: string): string[] { + if (!isValidIp(startIp) || !isValidIp(endIp)) { + throw new Error(`Invalid IP address: ${!isValidIp(startIp) ? startIp : endIp}`); + } + + const start = ipToNumber(startIp); + const end = ipToNumber(endIp); + + if (start > end) { + throw new Error(`Start IP (${startIp}) must be less than or equal to end IP (${endIp})`); + } + + const ips: string[] = []; + for (let i = start; i <= end; i++) { + ips.push(numberToIp(i)); + } + + return ips; +} + +/** + * Parses CIDR notation and returns an array of usable host IPs + * Excludes network address and broadcast address + * Example: "192.168.1.0/24" returns 192.168.1.1 through 192.168.1.254 + */ +export function cidrToIps(cidr: string): string[] { + const match = cidr.match(/^(\d+\.\d+\.\d+\.\d+)\/(\d+)$/); + if (!match) { + throw new Error(`Invalid CIDR notation: ${cidr}`); + } + + const [, networkIp, prefixStr] = match; + const prefix = parseInt(prefixStr, 10); + + if (!isValidIp(networkIp)) { + throw new Error(`Invalid network address: ${networkIp}`); + } + + if (prefix < 0 || prefix > 32) { + throw new Error(`Invalid prefix length: ${prefix}`); + } + + // For /32, just return the single IP + if (prefix === 32) { + return [networkIp]; + } + + // For /31 (point-to-point), return both IPs + if (prefix === 31) { + const networkNum = ipToNumber(networkIp); + const mask = (0xffffffff << (32 - prefix)) >>> 0; + const network = (networkNum & mask) >>> 0; + return [numberToIp(network), numberToIp(network + 1)]; + } + + // Calculate network and broadcast addresses + const networkNum = ipToNumber(networkIp); + const mask = (0xffffffff << (32 - prefix)) >>> 0; + const network = (networkNum & mask) >>> 0; + const broadcast = (network | (~mask >>> 0)) >>> 0; + + // Generate usable host IPs (exclude network and broadcast) + const ips: string[] = []; + for (let i = network + 1; i < broadcast; i++) { + ips.push(numberToIp(i)); + } + + return ips; +} + +/** + * Gets the local network interfaces and returns the first non-loopback IPv4 subnet + * Returns CIDR notation (e.g., "192.168.1.0/24") + */ +export function getLocalSubnet(): string | null { + try { + const os = require('os'); + const interfaces = os.networkInterfaces(); + + for (const name of Object.keys(interfaces)) { + const iface = interfaces[name]; + if (!iface) continue; + + for (const info of iface) { + // Skip loopback and non-IPv4 + if (info.family !== 'IPv4' || info.internal) continue; + + // Calculate the network address from IP and netmask + const ip = ipToNumber(info.address); + const mask = ipToNumber(info.netmask); + const network = (ip & mask) >>> 0; + + // Calculate prefix length from netmask + const maskBits = info.netmask.split('.').reduce((acc: number, octet: string) => { + const byte = parseInt(octet, 10); + let bits = 0; + for (let i = 7; i >= 0; i--) { + if ((byte >> i) & 1) bits++; + else break; + } + return acc + bits; + }, 0); + + return `${numberToIp(network)}/${maskBits}`; + } + } + } catch { + // os module might not be available + } + + return null; +} + +/** + * Counts the number of IPs in a CIDR range (excluding network and broadcast) + */ +export function countIpsInCidr(cidr: string): number { + const match = cidr.match(/^(\d+\.\d+\.\d+\.\d+)\/(\d+)$/); + if (!match) { + throw new Error(`Invalid CIDR notation: ${cidr}`); + } + + const prefix = parseInt(match[2], 10); + + if (prefix === 32) return 1; + if (prefix === 31) return 2; + + // 2^(32-prefix) - 2 (minus network and broadcast) + return Math.pow(2, 32 - prefix) - 2; +} diff --git a/ts/helpers/helpers.retry.ts b/ts/helpers/helpers.retry.ts new file mode 100644 index 0000000..b0158d1 --- /dev/null +++ b/ts/helpers/helpers.retry.ts @@ -0,0 +1,100 @@ +import * as plugins from '../plugins.js'; +import type { IRetryOptions } from '../interfaces/index.js'; + +const defaultRetryOptions: Required = { + maxRetries: 5, + baseDelay: 1000, + maxDelay: 16000, + multiplier: 2, + jitter: true, +}; + +/** + * Calculates the delay for a retry attempt using exponential backoff + */ +function calculateDelay( + attempt: number, + options: Required +): number { + const delay = Math.min( + options.baseDelay * Math.pow(options.multiplier, attempt), + options.maxDelay + ); + + if (options.jitter) { + // Add random jitter of +/- 25% + const jitterRange = delay * 0.25; + return delay + (Math.random() * jitterRange * 2 - jitterRange); + } + + return delay; +} + +/** + * Executes a function with retry logic using exponential backoff + * + * @param fn - The async function to execute + * @param options - Retry configuration options + * @returns The result of the function + * @throws The last error if all retries fail + */ +export async function withRetry( + fn: () => Promise, + options?: IRetryOptions +): Promise { + const opts: Required = { ...defaultRetryOptions, ...options }; + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= opts.maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + if (attempt === opts.maxRetries) { + break; + } + + const delay = calculateDelay(attempt, opts); + await plugins.smartdelay.delayFor(delay); + } + } + + throw lastError ?? new Error('Retry failed with unknown error'); +} + +/** + * Creates a retryable version of an async function + * + * @param fn - The async function to wrap + * @param options - Retry configuration options + * @returns A wrapped function that retries on failure + */ +export function createRetryable( + fn: (...args: TArgs) => Promise, + options?: IRetryOptions +): (...args: TArgs) => Promise { + return (...args: TArgs) => withRetry(() => fn(...args), options); +} + +/** + * Retry decorator for class methods + * Note: Use as a wrapper function since TC39 decorators have different semantics + */ +export function retryMethod>( + target: T, + methodName: keyof T & string, + options?: IRetryOptions +): void { + const originalMethod = target[methodName]; + if (typeof originalMethod !== 'function') { + throw new Error(`${methodName} is not a function`); + } + + target[methodName] = createRetryable( + originalMethod.bind(target) as (...args: unknown[]) => Promise, + options + ) as T[typeof methodName]; +} + +export { defaultRetryOptions }; diff --git a/ts/index.ts b/ts/index.ts index 8f1f224..7608784 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -1,3 +1,36 @@ -import * as plugins from './plugins.js'; +/** + * @push.rocks/devicemanager + * A device manager for discovering and communicating with network scanners and printers + */ -export let demoExport = 'Hi there! :) This is an exported string'; +// Main exports +export { + DeviceManager, + MdnsDiscovery, + NetworkScanner, + Scanner, + Printer, + SERVICE_TYPES, +} from './devicemanager.classes.devicemanager.js'; + +// Abstract/base classes +export { Device } from './abstract/device.abstract.js'; + +// Protocol implementations +export { EsclProtocol, SaneProtocol } from './scanner/scanner.classes.scanner.js'; +export { IppProtocol } from './printer/printer.classes.printer.js'; + +// Helpers +export { withRetry, createRetryable, defaultRetryOptions } from './helpers/helpers.retry.js'; +export { + isValidIp, + ipToNumber, + numberToIp, + ipRangeToIps, + cidrToIps, + getLocalSubnet, + countIpsInCidr, +} from './helpers/helpers.iprange.js'; + +// All interfaces and types +export * from './interfaces/index.js'; diff --git a/ts/interfaces/index.ts b/ts/interfaces/index.ts new file mode 100644 index 0000000..aa7823f --- /dev/null +++ b/ts/interfaces/index.ts @@ -0,0 +1,366 @@ +/** + * Device Manager Type Definitions + */ + +// ============================================================================ +// Device Types +// ============================================================================ + +export type TDeviceType = 'scanner' | 'printer' | 'snmp' | 'ups' | 'speaker' | 'dlna-renderer' | 'dlna-server'; +export type TDeviceStatus = 'online' | 'offline' | 'busy' | 'error' | 'unknown'; +export type TConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'; + +// ============================================================================ +// Scanner Types +// ============================================================================ + +export type TScannerProtocol = 'sane' | 'escl'; +export type TScanFormat = 'png' | 'jpeg' | 'pdf'; +export type TColorMode = 'color' | 'grayscale' | 'blackwhite'; +export type TScanSource = 'flatbed' | 'adf' | 'adf-duplex'; + +// ============================================================================ +// Base Interfaces +// ============================================================================ + +export interface IDeviceInfo { + id: string; + name: string; + type: TDeviceType; + address: string; + port: number; + status: TDeviceStatus; + manufacturer?: string; + model?: string; + serialNumber?: string; + firmwareVersion?: string; +} + +export interface IDeviceManagerOptions { + /** Enable auto-discovery on startup */ + autoDiscovery?: boolean; + /** Discovery timeout in milliseconds */ + discoveryTimeout?: number; + /** Enable retry with exponential backoff */ + enableRetry?: boolean; + /** Maximum retry attempts */ + maxRetries?: number; + /** Base delay for retry backoff in milliseconds */ + retryBaseDelay?: number; +} + +// ============================================================================ +// Scanner Interfaces +// ============================================================================ + +export interface IScannerInfo extends IDeviceInfo { + type: 'scanner'; + protocol: TScannerProtocol; + supportedFormats: TScanFormat[]; + supportedResolutions: number[]; + supportedColorModes: TColorMode[]; + supportedSources: TScanSource[]; + hasAdf: boolean; + hasDuplex: boolean; + maxWidth?: number; // in mm + maxHeight?: number; // in mm +} + +export interface IScanArea { + x: number; // X offset in mm + y: number; // Y offset in mm + width: number; // Width in mm + height: number; // Height in mm +} + +export interface IScanOptions { + /** Resolution in DPI (default: 300) */ + resolution?: number; + /** Output format (default: 'png') */ + format?: TScanFormat; + /** Color mode (default: 'color') */ + colorMode?: TColorMode; + /** Scan source (default: 'flatbed') */ + source?: TScanSource; + /** Scan area (default: full page) */ + area?: IScanArea; + /** Document intent for optimization */ + intent?: 'document' | 'photo' | 'preview'; + /** Compression quality for JPEG (1-100) */ + quality?: number; +} + +export interface IScanResult { + /** Scanned image data */ + data: Buffer; + /** Output format */ + format: TScanFormat; + /** Image width in pixels */ + width: number; + /** Image height in pixels */ + height: number; + /** Scan resolution in DPI */ + resolution: number; + /** Color mode used */ + colorMode: TColorMode; + /** MIME type */ + mimeType: string; +} + +export interface IScannerCapabilities { + resolutions: number[]; + formats: TScanFormat[]; + colorModes: TColorMode[]; + sources: TScanSource[]; + maxWidth: number; + maxHeight: number; + minWidth: number; + minHeight: number; +} + +// ============================================================================ +// Printer Interfaces +// ============================================================================ + +export interface IPrinterInfo extends IDeviceInfo { + type: 'printer'; + uri: string; + supportsColor: boolean; + supportsDuplex: boolean; + supportedMediaTypes: string[]; + supportedMediaSizes: string[]; + maxCopies: number; +} + +export interface IPrintOptions { + /** Number of copies (default: 1) */ + copies?: number; + /** Media size (e.g., 'iso_a4_210x297mm') */ + mediaSize?: string; + /** Media type (e.g., 'stationery') */ + mediaType?: string; + /** Print sides: 'one-sided', 'two-sided-long-edge', 'two-sided-short-edge' */ + sides?: 'one-sided' | 'two-sided-long-edge' | 'two-sided-short-edge'; + /** Print quality */ + quality?: 'draft' | 'normal' | 'high'; + /** Color mode */ + colorMode?: 'color' | 'monochrome'; + /** Job name */ + jobName?: string; +} + +export interface IPrintJob { + id: number; + name: string; + state: 'pending' | 'processing' | 'completed' | 'canceled' | 'aborted'; + stateReason?: string; + createdAt: Date; + completedAt?: Date; + pagesPrinted?: number; + pagesTotal?: number; +} + +export interface IPrinterCapabilities { + colorSupported: boolean; + duplexSupported: boolean; + mediaSizes: string[]; + mediaTypes: string[]; + resolutions: number[]; + maxCopies: number; + sidesSupported: string[]; + qualitySupported: string[]; +} + +// ============================================================================ +// Discovery Interfaces +// ============================================================================ + +export interface IDiscoveredDevice { + id: string; + name: string; + type: TDeviceType; + protocol: TScannerProtocol | 'ipp'; + address: string; + port: number; + txtRecords: Record; + serviceType: string; +} + +export interface IDiscoveryOptions { + /** Service types to discover */ + serviceTypes?: string[]; + /** Discovery timeout in ms */ + timeout?: number; +} + +// ============================================================================ +// Retry Configuration +// ============================================================================ + +export interface IRetryOptions { + /** Maximum number of retry attempts */ + maxRetries?: number; + /** Base delay in milliseconds */ + baseDelay?: number; + /** Maximum delay in milliseconds */ + maxDelay?: number; + /** Delay multiplier for exponential backoff */ + multiplier?: number; + /** Whether to add jitter to delays */ + jitter?: boolean; +} + +// ============================================================================ +// Event Types +// ============================================================================ + +export type TDeviceManagerEvents = { + 'scanner:found': (scanner: IScannerInfo) => void; + 'scanner:lost': (scannerId: string) => void; + 'printer:found': (printer: IPrinterInfo) => void; + 'printer:lost': (printerId: string) => void; + 'device:updated': (device: IDeviceInfo) => void; + 'discovery:started': () => void; + 'discovery:stopped': () => void; + 'error': (error: Error) => void; +}; + +// ============================================================================ +// eSCL Protocol Types +// ============================================================================ + +export interface IEsclCapabilities { + version: string; + makeAndModel: string; + serialNumber?: string; + uuid?: string; + adminUri?: string; + iconUri?: string; + platen?: IEsclInputSource; + adf?: IEsclInputSource; + adfDuplex?: IEsclInputSource; +} + +export interface IEsclInputSource { + minWidth: number; + maxWidth: number; + minHeight: number; + maxHeight: number; + maxScanRegions: number; + supportedResolutions: number[]; + colorModes: string[]; + documentFormats: string[]; +} + +export interface IEsclScanStatus { + state: 'Idle' | 'Processing' | 'Stopped' | 'Testing'; + adfState?: 'Empty' | 'Loaded' | 'Jammed' | 'Open' | 'Closed'; + jobs?: IEsclJobInfo[]; +} + +export interface IEsclJobInfo { + jobUri: string; + jobUuid: string; + age: number; + imagesCompleted: number; + imagesToTransfer: number; + jobState: 'Pending' | 'Processing' | 'Completed' | 'Canceled' | 'Aborted'; + jobStateReason?: string; +} + +// ============================================================================ +// SANE Protocol Types +// ============================================================================ + +export interface ISaneDevice { + name: string; + vendor: string; + model: string; + type: string; +} + +export interface ISaneOption { + name: string; + title: string; + description: string; + type: 'bool' | 'int' | 'fixed' | 'string' | 'button' | 'group'; + unit: 'none' | 'pixel' | 'bit' | 'mm' | 'dpi' | 'percent' | 'microsecond'; + size: number; + capabilities: number; + constraintType: 'none' | 'range' | 'word_list' | 'string_list'; + constraint?: ISaneConstraint; +} + +export interface ISaneConstraint { + range?: { min: number; max: number; quant: number }; + wordList?: number[]; + stringList?: string[]; +} + +export interface ISaneParameters { + format: 'gray' | 'rgb' | 'red' | 'green' | 'blue'; + lastFrame: boolean; + bytesPerLine: number; + pixelsPerLine: number; + lines: number; + depth: number; +} + +// ============================================================================ +// Network Scanner Interfaces (IP-based discovery) +// ============================================================================ + +export interface INetworkScanOptions { + /** CIDR notation (e.g., "192.168.1.0/24") */ + ipRange?: string; + /** Start IP address for range scan */ + startIp?: string; + /** End IP address for range scan */ + endIp?: string; + /** Maximum concurrent probes (default: 50) */ + concurrency?: number; + /** Timeout per probe in milliseconds (default: 2000) */ + timeout?: number; + /** Ports to probe (default: [80, 443, 631, 6566, 9100]) */ + ports?: number[]; + /** Check for eSCL scanners (default: true) */ + probeEscl?: boolean; + /** Check for IPP printers (default: true) */ + probeIpp?: boolean; + /** Check for SANE scanners (default: true) */ + probeSane?: boolean; +} + +export interface INetworkScanDevice { + type: 'scanner' | 'printer'; + protocol: 'escl' | 'sane' | 'ipp' | 'jetdirect'; + port: number; + name?: string; + model?: string; +} + +export interface INetworkScanResult { + address: string; + devices: INetworkScanDevice[]; +} + +export interface INetworkScanProgress { + /** Total IPs to scan */ + total: number; + /** IPs scanned so far */ + scanned: number; + /** Percentage complete (0-100) */ + percentage: number; + /** Current IP being scanned */ + currentIp?: string; + /** Devices found so far */ + devicesFound: number; +} + +export type TNetworkScannerEvents = { + 'progress': (progress: INetworkScanProgress) => void; + 'device:found': (result: INetworkScanResult) => void; + 'complete': (results: INetworkScanResult[]) => void; + 'error': (error: Error) => void; + 'cancelled': () => void; +}; diff --git a/ts/plugins.ts b/ts/plugins.ts index a7c8cf2..1a5d974 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -1,9 +1,34 @@ // native scope import * as path from 'path'; +import * as net from 'net'; +import * as dgram from 'dgram'; +import * as events from 'events'; -export { path }; +export { path, net, dgram, events }; // @push.rocks scope import * as smartpath from '@push.rocks/smartpath'; +import * as smartpromise from '@push.rocks/smartpromise'; +import * as smartdelay from '@push.rocks/smartdelay'; +import * as smartnetwork from '@push.rocks/smartnetwork'; -export { smartpath }; +// Re-export SmartRequest for HTTP +import { SmartRequest } from '@push.rocks/smartrequest'; +export { SmartRequest }; + +export { + smartpath, + smartpromise, + smartdelay, + smartnetwork, +}; + +// third party +import * as bonjourService from 'bonjour-service'; +import ipp from 'ipp'; +import * as nodeSsdp from 'node-ssdp'; +import * as netSnmp from 'net-snmp'; +import * as sonos from 'sonos'; +import * as castv2Client from 'castv2-client'; + +export { bonjourService, ipp, nodeSsdp, netSnmp, sonos, castv2Client }; diff --git a/ts/printer/printer.classes.ippprotocol.ts b/ts/printer/printer.classes.ippprotocol.ts new file mode 100644 index 0000000..1302928 --- /dev/null +++ b/ts/printer/printer.classes.ippprotocol.ts @@ -0,0 +1,329 @@ +import * as plugins from '../plugins.js'; +import type { + IPrinterCapabilities, + IPrintOptions, + IPrintJob, +} from '../interfaces/index.js'; + +/** + * IPP protocol wrapper using the ipp npm package + */ +export class IppProtocol { + private printerUrl: string; + private printer: ReturnType; + + constructor(address: string, port: number, path: string = '/ipp/print') { + this.printerUrl = `ipp://${address}:${port}${path}`; + this.printer = plugins.ipp.Printer(this.printerUrl); + } + + /** + * Get printer attributes/capabilities + */ + public async getAttributes(): Promise { + return new Promise((resolve, reject) => { + this.printer.execute( + 'Get-Printer-Attributes', + null, + (err: Error | null, res: Record) => { + if (err) { + reject(err); + return; + } + + try { + const attrs = res['printer-attributes-tag'] as Record || {}; + resolve(this.parseCapabilities(attrs)); + } catch (parseErr) { + reject(parseErr); + } + } + ); + }); + } + + /** + * Print a document + */ + public async print(data: Buffer, options?: IPrintOptions): Promise { + const msg = this.buildPrintMessage(options); + + return new Promise((resolve, reject) => { + this.printer.execute( + 'Print-Job', + { ...msg, data }, + (err: Error | null, res: Record) => { + if (err) { + reject(err); + return; + } + + try { + const jobAttrs = res['job-attributes-tag'] as Record || {}; + resolve(this.parseJobInfo(jobAttrs)); + } catch (parseErr) { + reject(parseErr); + } + } + ); + }); + } + + /** + * Get all jobs + */ + public async getJobs(): Promise { + return new Promise((resolve, reject) => { + this.printer.execute( + 'Get-Jobs', + { + 'operation-attributes-tag': { + 'requesting-user-name': 'devicemanager', + 'which-jobs': 'not-completed', + }, + }, + (err: Error | null, res: Record) => { + if (err) { + reject(err); + return; + } + + try { + const jobs: IPrintJob[] = []; + const jobTags = res['job-attributes-tag']; + + if (Array.isArray(jobTags)) { + for (const jobAttrs of jobTags) { + jobs.push(this.parseJobInfo(jobAttrs as Record)); + } + } else if (jobTags && typeof jobTags === 'object') { + jobs.push(this.parseJobInfo(jobTags as Record)); + } + + resolve(jobs); + } catch (parseErr) { + reject(parseErr); + } + } + ); + }); + } + + /** + * Get specific job info + */ + public async getJobInfo(jobId: number): Promise { + return new Promise((resolve, reject) => { + this.printer.execute( + 'Get-Job-Attributes', + { + 'operation-attributes-tag': { + 'job-id': jobId, + }, + }, + (err: Error | null, res: Record) => { + if (err) { + reject(err); + return; + } + + try { + const jobAttrs = res['job-attributes-tag'] as Record || {}; + resolve(this.parseJobInfo(jobAttrs)); + } catch (parseErr) { + reject(parseErr); + } + } + ); + }); + } + + /** + * Cancel a job + */ + public async cancelJob(jobId: number): Promise { + return new Promise((resolve, reject) => { + this.printer.execute( + 'Cancel-Job', + { + 'operation-attributes-tag': { + 'job-id': jobId, + }, + }, + (err: Error | null, _res: Record) => { + if (err) { + reject(err); + return; + } + resolve(); + } + ); + }); + } + + /** + * Check if printer is available + */ + public async checkAvailability(): Promise { + try { + await this.getAttributes(); + return true; + } catch { + return false; + } + } + + /** + * Build IPP print message from options + */ + private buildPrintMessage(options?: IPrintOptions): Record { + const operationAttrs: Record = { + 'requesting-user-name': 'devicemanager', + 'job-name': options?.jobName ?? 'Print Job', + 'document-format': 'application/octet-stream', + }; + + const jobAttrs: Record = {}; + + if (options?.copies && options.copies > 1) { + jobAttrs['copies'] = options.copies; + } + + if (options?.mediaSize) { + jobAttrs['media'] = options.mediaSize; + } + + if (options?.mediaType) { + jobAttrs['media-type'] = options.mediaType; + } + + if (options?.sides) { + jobAttrs['sides'] = options.sides; + } + + if (options?.quality) { + const qualityMap: Record = { + draft: 3, + normal: 4, + high: 5, + }; + jobAttrs['print-quality'] = qualityMap[options.quality] ?? 4; + } + + if (options?.colorMode) { + jobAttrs['print-color-mode'] = options.colorMode; + } + + const msg: Record = { + 'operation-attributes-tag': operationAttrs, + }; + + if (Object.keys(jobAttrs).length > 0) { + msg['job-attributes-tag'] = jobAttrs; + } + + return msg; + } + + /** + * Parse printer capabilities from attributes + */ + private parseCapabilities(attrs: Record): IPrinterCapabilities { + const getArray = (key: string): string[] => { + const value = attrs[key]; + if (Array.isArray(value)) return value.map(String); + if (value !== undefined) return [String(value)]; + return []; + }; + + const getNumber = (key: string, defaultVal: number): number => { + const value = attrs[key]; + if (typeof value === 'number') return value; + if (typeof value === 'string') return parseInt(value) || defaultVal; + return defaultVal; + }; + + const getBool = (key: string, defaultVal: boolean): boolean => { + const value = attrs[key]; + if (typeof value === 'boolean') return value; + if (value === 'true' || value === 1) return true; + if (value === 'false' || value === 0) return false; + return defaultVal; + }; + + // Parse resolutions + const resolutions: number[] = []; + const resSupported = attrs['printer-resolution-supported']; + if (Array.isArray(resSupported)) { + for (const res of resSupported) { + if (typeof res === 'object' && res !== null && 'x' in res) { + resolutions.push((res as { x: number }).x); + } else if (typeof res === 'number') { + resolutions.push(res); + } + } + } + if (resolutions.length === 0) { + resolutions.push(300, 600); + } + + return { + colorSupported: getBool('color-supported', false), + duplexSupported: + getArray('sides-supported').some((s) => + s.includes('two-sided') + ), + mediaSizes: getArray('media-supported'), + mediaTypes: getArray('media-type-supported'), + resolutions: [...new Set(resolutions)], + maxCopies: getNumber('copies-supported', 99), + sidesSupported: getArray('sides-supported'), + qualitySupported: getArray('print-quality-supported').map(String), + }; + } + + /** + * Parse job info from attributes + */ + private parseJobInfo(attrs: Record): IPrintJob { + const getString = (key: string, defaultVal: string): string => { + const value = attrs[key]; + if (typeof value === 'string') return value; + if (value !== undefined) return String(value); + return defaultVal; + }; + + const getNumber = (key: string, defaultVal: number): number => { + const value = attrs[key]; + if (typeof value === 'number') return value; + if (typeof value === 'string') return parseInt(value) || defaultVal; + return defaultVal; + }; + + // Map IPP job state to our state + const ippState = getNumber('job-state', 3); + const stateMap: Record = { + 3: 'pending', // pending + 4: 'pending', // pending-held + 5: 'processing', // processing + 6: 'processing', // processing-stopped + 7: 'canceled', // canceled + 8: 'aborted', // aborted + 9: 'completed', // completed + }; + + const createdTime = attrs['time-at-creation']; + const completedTime = attrs['time-at-completed']; + + return { + id: getNumber('job-id', 0), + name: getString('job-name', 'Unknown Job'), + state: stateMap[ippState] ?? 'pending', + stateReason: getString('job-state-reasons', undefined), + createdAt: createdTime ? new Date(createdTime as number * 1000) : new Date(), + completedAt: completedTime ? new Date(completedTime as number * 1000) : undefined, + pagesPrinted: getNumber('job-media-sheets-completed', undefined), + pagesTotal: getNumber('job-media-sheets', undefined), + }; + } +} diff --git a/ts/printer/printer.classes.printer.ts b/ts/printer/printer.classes.printer.ts new file mode 100644 index 0000000..3ed3b81 --- /dev/null +++ b/ts/printer/printer.classes.printer.ts @@ -0,0 +1,255 @@ +import { Device } from '../abstract/device.abstract.js'; +import { IppProtocol } from './printer.classes.ippprotocol.js'; +import type { + IPrinterInfo, + IPrinterCapabilities, + IPrintOptions, + IPrintJob, + IRetryOptions, +} from '../interfaces/index.js'; + +/** + * Printer class for IPP network printers + */ +export class Printer extends Device { + public readonly uri: string; + public supportsColor: boolean = false; + public supportsDuplex: boolean = false; + public supportedMediaTypes: string[] = []; + public supportedMediaSizes: string[] = []; + public maxCopies: number = 99; + + private ippClient: IppProtocol | null = null; + private ippPath: string; + + constructor( + info: IPrinterInfo, + options?: { + ippPath?: string; + retryOptions?: IRetryOptions; + } + ) { + super(info, options?.retryOptions); + this.uri = info.uri; + this.supportsColor = info.supportsColor; + this.supportsDuplex = info.supportsDuplex; + this.supportedMediaTypes = info.supportedMediaTypes; + this.supportedMediaSizes = info.supportedMediaSizes; + this.maxCopies = info.maxCopies; + this.ippPath = options?.ippPath ?? '/ipp/print'; + } + + /** + * Create a Printer from discovery info + */ + public static fromDiscovery( + discoveredDevice: { + id: string; + name: string; + address: string; + port: number; + txtRecords: Record; + }, + retryOptions?: IRetryOptions + ): Printer { + // Parse capabilities from TXT records + const txtRecords = discoveredDevice.txtRecords; + + // Get IPP path from TXT records + const rp = txtRecords['rp'] || 'ipp/print'; + const ippPath = rp.startsWith('/') ? rp : `/${rp}`; + + // Parse color support + const colorSupported = + txtRecords['Color'] === 'T' || + txtRecords['color'] === 'true' || + txtRecords['URF']?.includes('W8') || + false; + + // Parse duplex support + const duplexSupported = + txtRecords['Duplex'] === 'T' || + txtRecords['duplex'] === 'true' || + txtRecords['URF']?.includes('DM') || + false; + + // Build printer URI + const isSecure = txtRecords['TLS'] === '1' || discoveredDevice.port === 443; + const protocol = isSecure ? 'ipps' : 'ipp'; + const uri = `${protocol}://${discoveredDevice.address}:${discoveredDevice.port}${ippPath}`; + + const info: IPrinterInfo = { + id: discoveredDevice.id, + name: discoveredDevice.name, + type: 'printer', + address: discoveredDevice.address, + port: discoveredDevice.port, + status: 'online', + uri: uri, + supportsColor: colorSupported, + supportsDuplex: duplexSupported, + supportedMediaTypes: [], + supportedMediaSizes: [], + maxCopies: 99, + manufacturer: txtRecords['usb_MFG'] || txtRecords['mfg'], + model: txtRecords['usb_MDL'] || txtRecords['mdl'] || txtRecords['ty'], + }; + + return new Printer(info, { ippPath, retryOptions }); + } + + /** + * Get printer info + */ + public getPrinterInfo(): IPrinterInfo { + return { + ...this.getInfo(), + type: 'printer', + uri: this.uri, + supportsColor: this.supportsColor, + supportsDuplex: this.supportsDuplex, + supportedMediaTypes: this.supportedMediaTypes, + supportedMediaSizes: this.supportedMediaSizes, + maxCopies: this.maxCopies, + }; + } + + /** + * Get printer capabilities + */ + public async getCapabilities(): Promise { + if (!this.isConnected) { + await this.connect(); + } + + if (!this.ippClient) { + throw new Error('IPP client not initialized'); + } + + const caps = await this.withRetry(() => this.ippClient!.getAttributes()); + + // Update local properties + this.supportsColor = caps.colorSupported; + this.supportsDuplex = caps.duplexSupported; + this.supportedMediaSizes = caps.mediaSizes; + this.supportedMediaTypes = caps.mediaTypes; + this.maxCopies = caps.maxCopies; + + return caps; + } + + /** + * Print a document + */ + public async print(data: Buffer, options?: IPrintOptions): Promise { + if (!this.isConnected) { + await this.connect(); + } + + if (!this.ippClient) { + throw new Error('IPP client not initialized'); + } + + this.setStatus('busy'); + this.emit('print:started', options); + + try { + const job = await this.withRetry(() => this.ippClient!.print(data, options)); + this.setStatus('online'); + this.emit('print:submitted', job); + return job; + } catch (error) { + this.setStatus('online'); + this.emit('print:error', error); + throw error; + } + } + + /** + * Get all print jobs + */ + public async getJobs(): Promise { + if (!this.isConnected) { + await this.connect(); + } + + if (!this.ippClient) { + throw new Error('IPP client not initialized'); + } + + return this.withRetry(() => this.ippClient!.getJobs()); + } + + /** + * Get specific job info + */ + public async getJobInfo(jobId: number): Promise { + if (!this.isConnected) { + await this.connect(); + } + + if (!this.ippClient) { + throw new Error('IPP client not initialized'); + } + + return this.withRetry(() => this.ippClient!.getJobInfo(jobId)); + } + + /** + * Cancel a print job + */ + public async cancelJob(jobId: number): Promise { + if (!this.isConnected) { + await this.connect(); + } + + if (!this.ippClient) { + throw new Error('IPP client not initialized'); + } + + await this.withRetry(() => this.ippClient!.cancelJob(jobId)); + this.emit('print:canceled', jobId); + } + + /** + * Connect to the printer + */ + protected async doConnect(): Promise { + this.ippClient = new IppProtocol(this.address, this.port, this.ippPath); + + // Test connection by checking availability + const available = await this.ippClient.checkAvailability(); + if (!available) { + throw new Error('Printer not available'); + } + + // Fetch capabilities to populate local properties + await this.getCapabilities(); + } + + /** + * Disconnect from the printer + */ + protected async doDisconnect(): Promise { + this.ippClient = null; + } + + /** + * Refresh printer status + */ + public async refreshStatus(): Promise { + try { + if (this.ippClient) { + const available = await this.ippClient.checkAvailability(); + this.setStatus(available ? 'online' : 'offline'); + } else { + this.setStatus('offline'); + } + } catch (error) { + this.setStatus('error'); + throw error; + } + } +} + +export { IppProtocol }; diff --git a/ts/scanner/scanner.classes.esclprotocol.ts b/ts/scanner/scanner.classes.esclprotocol.ts new file mode 100644 index 0000000..351ae99 --- /dev/null +++ b/ts/scanner/scanner.classes.esclprotocol.ts @@ -0,0 +1,423 @@ +import * as plugins from '../plugins.js'; // Used for smartdelay +import type { + IEsclCapabilities, + IEsclScanStatus, + IEsclJobInfo, + IScanOptions, + IScanResult, + TScanFormat, + TColorMode, +} from '../interfaces/index.js'; + +/** + * eSCL XML namespaces + */ +const NAMESPACES = { + scan: 'http://schemas.hp.com/imaging/escl/2011/05/03', + pwg: 'http://www.pwg.org/schemas/2010/12/sm', +}; + +/** + * Color mode mappings + */ +const COLOR_MODE_MAP: Record = { + color: 'RGB24', + grayscale: 'Grayscale8', + blackwhite: 'BlackAndWhite1', +}; + +/** + * Format MIME type mappings + */ +const FORMAT_MIME_MAP: Record = { + jpeg: 'image/jpeg', + png: 'image/png', + pdf: 'application/pdf', +}; + +/** + * Helper to make HTTP requests using native fetch (available in Node.js 18+) + */ +async function httpRequest( + url: string, + method: 'GET' | 'POST' | 'DELETE', + body?: string +): Promise<{ status: number; headers: Record; body: string }> { + const options: RequestInit = { method }; + + if (body) { + options.headers = { 'Content-Type': 'text/xml; charset=utf-8' }; + options.body = body; + } + + const response = await fetch(url, options); + const responseBody = await response.text(); + + const headers: Record = {}; + response.headers.forEach((value, key) => { + headers[key.toLowerCase()] = value; + }); + + return { + status: response.status, + headers, + body: responseBody, + }; +} + +/** + * eSCL/AirScan protocol client for network scanners + */ +export class EsclProtocol { + private baseUrl: string; + private capabilities: IEsclCapabilities | null = null; + + constructor(address: string, port: number, secure: boolean = false) { + const protocol = secure ? 'https' : 'http'; + this.baseUrl = `${protocol}://${address}:${port}/eSCL`; + } + + /** + * Get scanner capabilities + */ + public async getCapabilities(): Promise { + const response = await httpRequest( + `${this.baseUrl}/ScannerCapabilities`, + 'GET' + ); + + if (response.status !== 200) { + throw new Error(`Failed to get capabilities: HTTP ${response.status}`); + } + + this.capabilities = this.parseCapabilities(response.body); + return this.capabilities; + } + + /** + * Get scanner status + */ + public async getStatus(): Promise { + const response = await httpRequest( + `${this.baseUrl}/ScannerStatus`, + 'GET' + ); + + if (response.status !== 200) { + throw new Error(`Failed to get status: HTTP ${response.status}`); + } + + return this.parseStatus(response.body); + } + + /** + * Submit a scan job + * Returns the job URI for tracking + */ + public async submitScanJob(options: IScanOptions): Promise { + const scanSettings = this.buildScanSettings(options); + + // Use fetch for POST with body since SmartRequest API may not support raw body + const response = await fetch(`${this.baseUrl}/ScanJobs`, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + }, + body: scanSettings, + }); + + if (response.status !== 201) { + throw new Error(`Failed to submit scan job: HTTP ${response.status}`); + } + + // Get job URI from Location header + const location = response.headers.get('location'); + if (!location) { + throw new Error('No job location returned from scanner'); + } + + return location; + } + + /** + * Wait for scan job to complete and download the result + */ + public async waitForScanComplete( + jobUri: string, + options: IScanOptions, + pollInterval: number = 500 + ): Promise { + // Poll until job is complete + let attempts = 0; + const maxAttempts = 120; // 60 seconds max + + while (attempts < maxAttempts) { + const status = await this.getStatus(); + const job = status.jobs?.find((j) => jobUri.includes(j.jobUuid) || j.jobUri === jobUri); + + if (job) { + if (job.jobState === 'Completed') { + break; + } else if (job.jobState === 'Canceled' || job.jobState === 'Aborted') { + throw new Error(`Scan job ${job.jobState}: ${job.jobStateReason || 'Unknown reason'}`); + } + } + + await plugins.smartdelay.delayFor(pollInterval); + attempts++; + } + + if (attempts >= maxAttempts) { + throw new Error('Scan job timed out'); + } + + // Download the scanned document + return this.downloadScan(jobUri, options); + } + + /** + * Download scanned document + */ + public async downloadScan(jobUri: string, options: IScanOptions): Promise { + const downloadUrl = `${jobUri}/NextDocument`; + + const response = await fetch(downloadUrl, { method: 'GET' }); + + if (response.status !== 200) { + throw new Error(`Failed to download scan: HTTP ${response.status}`); + } + + const format = options.format ?? 'jpeg'; + const contentType = response.headers.get('content-type') ?? FORMAT_MIME_MAP[format]; + + // Get image dimensions from headers if available + const width = parseInt(response.headers.get('x-image-width') || '0') || 0; + const height = parseInt(response.headers.get('x-image-height') || '0') || 0; + + const arrayBuffer = await response.arrayBuffer(); + const data = Buffer.from(arrayBuffer); + + return { + data: data, + format: format, + width: width, + height: height, + resolution: options.resolution ?? 300, + colorMode: options.colorMode ?? 'color', + mimeType: contentType, + }; + } + + /** + * Cancel a scan job + */ + public async cancelJob(jobUri: string): Promise { + const response = await fetch(jobUri, { method: 'DELETE' }); + + // 204 No Content or 200 OK are both acceptable + if (response.status !== 200 && response.status !== 204) { + throw new Error(`Failed to cancel job: HTTP ${response.status}`); + } + } + + /** + * Build XML scan settings + */ + private buildScanSettings(options: IScanOptions): string { + const resolution = options.resolution ?? 300; + const colorMode = COLOR_MODE_MAP[options.colorMode ?? 'color']; + const format = FORMAT_MIME_MAP[options.format ?? 'jpeg']; + const source = this.mapSource(options.source ?? 'flatbed'); + const intent = options.intent ?? 'TextAndPhoto'; + + let xml = ` + + 2.0 + ${intent} + + `; + + if (options.area) { + // Convert mm to 300ths of an inch (eSCL uses 300dpi as base unit) + const toUnits = (mm: number) => Math.round((mm / 25.4) * 300); + xml += ` + ${toUnits(options.area.x)} + ${toUnits(options.area.y)} + ${toUnits(options.area.width)} + ${toUnits(options.area.height)}`; + } else { + // Full page (A4 default: 210x297mm) + xml += ` + 0 + 0 + 2480 + 3508`; + } + + xml += ` + escl:ThreeHundredthsOfInches + + + ${format} + ${resolution} + ${resolution} + ${colorMode} + ${source}`; + + if (options.format === 'jpeg' && options.quality) { + xml += ` + ${100 - options.quality}`; + } + + xml += ` +`; + + return xml; + } + + /** + * Map source to eSCL format + */ + private mapSource(source: string): string { + switch (source) { + case 'flatbed': + return 'Platen'; + case 'adf': + return 'Feeder'; + case 'adf-duplex': + return 'Duplex'; + default: + return 'Platen'; + } + } + + /** + * Parse capabilities XML response + */ + private parseCapabilities(body: string): IEsclCapabilities { + const xml = body; + + // Simple XML parsing without full XML parser + const getTagContent = (tag: string): string => { + const regex = new RegExp(`<[^:]*:?${tag}[^>]*>([^<]*)<`, 'i'); + const match = xml.match(regex); + return match?.[1]?.trim() ?? ''; + }; + + const getAllTagContents = (tag: string): string[] => { + const regex = new RegExp(`<[^:]*:?${tag}[^>]*>([^<]*)<`, 'gi'); + const matches: string[] = []; + let match; + while ((match = regex.exec(xml)) !== null) { + if (match[1]?.trim()) { + matches.push(match[1].trim()); + } + } + return matches; + }; + + const parseResolutions = (): number[] => { + const resolutions = getAllTagContents('XResolution'); + return [...new Set(resolutions.map((r) => parseInt(r)).filter((r) => !isNaN(r)))]; + }; + + return { + version: getTagContent('Version') || '2.0', + makeAndModel: getTagContent('MakeAndModel') || getTagContent('Make') || 'Unknown', + serialNumber: getTagContent('SerialNumber') || undefined, + uuid: getTagContent('UUID') || undefined, + adminUri: getTagContent('AdminURI') || undefined, + iconUri: getTagContent('IconURI') || undefined, + platen: xml.includes('Platen') + ? { + minWidth: parseInt(getTagContent('MinWidth')) || 0, + maxWidth: parseInt(getTagContent('MaxWidth')) || 2550, + minHeight: parseInt(getTagContent('MinHeight')) || 0, + maxHeight: parseInt(getTagContent('MaxHeight')) || 3508, + maxScanRegions: parseInt(getTagContent('MaxScanRegions')) || 1, + supportedResolutions: parseResolutions(), + colorModes: getAllTagContents('ColorMode'), + documentFormats: getAllTagContents('DocumentFormatExt'), + } + : undefined, + adf: xml.includes('Adf') || xml.includes('ADF') || xml.includes('Feeder') + ? { + minWidth: 0, + maxWidth: 2550, + minHeight: 0, + maxHeight: 4200, + maxScanRegions: 1, + supportedResolutions: parseResolutions(), + colorModes: getAllTagContents('ColorMode'), + documentFormats: getAllTagContents('DocumentFormatExt'), + } + : undefined, + }; + } + + /** + * Parse status XML response + */ + private parseStatus(body: string): IEsclScanStatus { + const xml = body; + + const getTagContent = (tag: string): string => { + const regex = new RegExp(`<[^:]*:?${tag}[^>]*>([^<]*)<`, 'i'); + const match = xml.match(regex); + return match?.[1]?.trim() ?? ''; + }; + + const state = getTagContent('State') || getTagContent('ScannerState') || 'Idle'; + const adfState = getTagContent('AdfState') || undefined; + + // Parse jobs if present + const jobs: IEsclJobInfo[] = []; + const jobMatches = xml.match(/<[^:]*:?JobInfo[^>]*>[\s\S]*?<\/[^:]*:?JobInfo>/gi); + if (jobMatches) { + for (const jobXml of jobMatches) { + const getJobTag = (tag: string): string => { + const regex = new RegExp(`<[^:]*:?${tag}[^>]*>([^<]*)<`, 'i'); + const match = jobXml.match(regex); + return match?.[1]?.trim() ?? ''; + }; + + jobs.push({ + jobUri: getJobTag('JobUri') || getJobTag('JobURI') || '', + jobUuid: getJobTag('JobUuid') || getJobTag('JobUUID') || '', + age: parseInt(getJobTag('Age')) || 0, + imagesCompleted: parseInt(getJobTag('ImagesCompleted')) || 0, + imagesToTransfer: parseInt(getJobTag('ImagesToTransfer')) || 0, + jobState: (getJobTag('JobState') as IEsclJobInfo['jobState']) || 'Pending', + jobStateReason: getJobTag('JobStateReason') || undefined, + }); + } + } + + return { + state: state as IEsclScanStatus['state'], + adfState: adfState as IEsclScanStatus['adfState'], + jobs: jobs.length > 0 ? jobs : undefined, + }; + } + + /** + * Perform a complete scan operation + */ + public async scan(options: IScanOptions): Promise { + // Submit the job + const jobUri = await this.submitScanJob(options); + + try { + // Wait for completion and download + return await this.waitForScanComplete(jobUri, options); + } catch (error) { + // Try to cancel the job on error + try { + await this.cancelJob(jobUri); + } catch { + // Ignore cancel errors + } + throw error; + } + } +} diff --git a/ts/scanner/scanner.classes.saneprotocol.ts b/ts/scanner/scanner.classes.saneprotocol.ts new file mode 100644 index 0000000..967d89c --- /dev/null +++ b/ts/scanner/scanner.classes.saneprotocol.ts @@ -0,0 +1,694 @@ +import * as plugins from '../plugins.js'; +import type { + ISaneDevice, + ISaneOption, + ISaneParameters, + IScanOptions, + IScanResult, + TColorMode, +} from '../interfaces/index.js'; + +/** + * SANE network protocol RPC codes + */ +const enum SaneRpc { + INIT = 0, + GET_DEVICES = 1, + OPEN = 2, + CLOSE = 3, + GET_OPTION_DESCRIPTORS = 4, + CONTROL_OPTION = 5, + GET_PARAMETERS = 6, + START = 7, + CANCEL = 8, + AUTHORIZE = 9, + EXIT = 10, +} + +/** + * SANE status codes + */ +const enum SaneStatus { + GOOD = 0, + UNSUPPORTED = 1, + CANCELLED = 2, + DEVICE_BUSY = 3, + INVAL = 4, + EOF = 5, + JAMMED = 6, + NO_DOCS = 7, + COVER_OPEN = 8, + IO_ERROR = 9, + NO_MEM = 10, + ACCESS_DENIED = 11, +} + +/** + * SANE option types + */ +const enum SaneValueType { + BOOL = 0, + INT = 1, + FIXED = 2, + STRING = 3, + BUTTON = 4, + GROUP = 5, +} + +/** + * SANE option units + */ +const enum SaneUnit { + NONE = 0, + PIXEL = 1, + BIT = 2, + MM = 3, + DPI = 4, + PERCENT = 5, + MICROSECOND = 6, +} + +/** + * SANE constraint types + */ +const enum SaneConstraintType { + NONE = 0, + RANGE = 1, + WORD_LIST = 2, + STRING_LIST = 3, +} + +/** + * SANE control option actions + */ +const enum SaneAction { + GET_VALUE = 0, + SET_VALUE = 1, + SET_AUTO = 2, +} + +const SANE_PORT = 6566; +const SANE_NET_PROTOCOL_VERSION = 3; + +/** + * Status code to error message mapping + */ +const STATUS_MESSAGES: Record = { + [SaneStatus.GOOD]: 'Success', + [SaneStatus.UNSUPPORTED]: 'Operation not supported', + [SaneStatus.CANCELLED]: 'Operation cancelled', + [SaneStatus.DEVICE_BUSY]: 'Device busy', + [SaneStatus.INVAL]: 'Invalid argument', + [SaneStatus.EOF]: 'End of file', + [SaneStatus.JAMMED]: 'Document feeder jammed', + [SaneStatus.NO_DOCS]: 'No documents in feeder', + [SaneStatus.COVER_OPEN]: 'Scanner cover open', + [SaneStatus.IO_ERROR]: 'I/O error', + [SaneStatus.NO_MEM]: 'Out of memory', + [SaneStatus.ACCESS_DENIED]: 'Access denied', +}; + +/** + * SANE network protocol client + */ +export class SaneProtocol { + private socket: plugins.net.Socket | null = null; + private address: string; + private port: number; + private handle: number = -1; + private options: ISaneOption[] = []; + private readBuffer: Buffer = Buffer.alloc(0); + + constructor(address: string, port: number = SANE_PORT) { + this.address = address; + this.port = port; + } + + /** + * Connect to SANE daemon + */ + public async connect(): Promise { + return new Promise((resolve, reject) => { + this.socket = plugins.net.createConnection( + { host: this.address, port: this.port }, + () => { + this.init() + .then(() => resolve()) + .catch(reject); + } + ); + + this.socket.on('error', reject); + this.socket.on('data', (data: Buffer) => { + this.readBuffer = Buffer.concat([this.readBuffer, data]); + }); + }); + } + + /** + * Disconnect from SANE daemon + */ + public async disconnect(): Promise { + if (this.handle >= 0) { + await this.close(); + } + + await this.exit(); + + if (this.socket) { + this.socket.destroy(); + this.socket = null; + } + } + + /** + * Initialize connection (SANE_NET_INIT) + */ + private async init(): Promise { + const request = this.buildRequest(SaneRpc.INIT); + this.writeWord(request, SANE_NET_PROTOCOL_VERSION); + this.writeString(request, ''); // Username (empty for now) + + await this.sendRequest(request); + const response = await this.readResponse(); + + const status = this.readWord(response); + const version = this.readWord(response); + + if (status !== SaneStatus.GOOD) { + throw new Error(`SANE init failed: ${STATUS_MESSAGES[status] || 'Unknown error'}`); + } + } + + /** + * Get available devices (SANE_NET_GET_DEVICES) + */ + public async getDevices(): Promise { + const request = this.buildRequest(SaneRpc.GET_DEVICES); + await this.sendRequest(request); + const response = await this.readResponse(); + + const status = this.readWord(response); + if (status !== SaneStatus.GOOD) { + throw new Error(`Failed to get devices: ${STATUS_MESSAGES[status]}`); + } + + const devices: ISaneDevice[] = []; + const count = this.readWord(response); + + for (let i = 0; i < count; i++) { + const hasDevice = this.readWord(response); + if (hasDevice) { + devices.push({ + name: this.readString(response), + vendor: this.readString(response), + model: this.readString(response), + type: this.readString(response), + }); + } + } + + return devices; + } + + /** + * Open a device (SANE_NET_OPEN) + */ + public async open(deviceName: string): Promise { + const request = this.buildRequest(SaneRpc.OPEN); + this.writeString(request, deviceName); + + await this.sendRequest(request); + const response = await this.readResponse(); + + const status = this.readWord(response); + if (status !== SaneStatus.GOOD) { + // Check for authorization required + const resource = this.readString(response); + if (resource) { + throw new Error(`Authorization required for: ${resource}`); + } + throw new Error(`Failed to open device: ${STATUS_MESSAGES[status]}`); + } + + this.handle = this.readWord(response); + this.readString(response); // resource (should be empty on success) + + // Get option descriptors + await this.getOptionDescriptors(); + } + + /** + * Close the current device (SANE_NET_CLOSE) + */ + public async close(): Promise { + if (this.handle < 0) { + return; + } + + const request = this.buildRequest(SaneRpc.CLOSE); + this.writeWord(request, this.handle); + + await this.sendRequest(request); + await this.readResponse(); // Just read to clear buffer + + this.handle = -1; + this.options = []; + } + + /** + * Exit connection (SANE_NET_EXIT) + */ + private async exit(): Promise { + const request = this.buildRequest(SaneRpc.EXIT); + await this.sendRequest(request); + } + + /** + * Get option descriptors (SANE_NET_GET_OPTION_DESCRIPTORS) + */ + private async getOptionDescriptors(): Promise { + const request = this.buildRequest(SaneRpc.GET_OPTION_DESCRIPTORS); + this.writeWord(request, this.handle); + + await this.sendRequest(request); + const response = await this.readResponse(); + + const count = this.readWord(response); + this.options = []; + + for (let i = 0; i < count; i++) { + const hasOption = this.readWord(response); + if (!hasOption) { + continue; + } + + const option: ISaneOption = { + name: this.readString(response), + title: this.readString(response), + description: this.readString(response), + type: this.mapValueType(this.readWord(response)), + unit: this.mapUnit(this.readWord(response)), + size: this.readWord(response), + capabilities: this.readWord(response), + constraintType: this.mapConstraintType(this.readWord(response)), + }; + + // Read constraint based on type + if (option.constraintType === 'range') { + option.constraint = { + range: { + min: this.readWord(response), + max: this.readWord(response), + quant: this.readWord(response), + }, + }; + } else if (option.constraintType === 'word_list') { + const wordCount = this.readWord(response); + const words: number[] = []; + for (let j = 0; j < wordCount; j++) { + words.push(this.readWord(response)); + } + option.constraint = { wordList: words }; + } else if (option.constraintType === 'string_list') { + const strings: string[] = []; + let str: string; + while ((str = this.readString(response)) !== '') { + strings.push(str); + } + option.constraint = { stringList: strings }; + } + + this.options.push(option); + } + } + + /** + * Set an option value + */ + public async setOption(name: string, value: unknown): Promise { + const optionIndex = this.options.findIndex((o) => o.name === name); + if (optionIndex < 0) { + throw new Error(`Unknown option: ${name}`); + } + + const option = this.options[optionIndex]; + const request = this.buildRequest(SaneRpc.CONTROL_OPTION); + + this.writeWord(request, this.handle); + this.writeWord(request, optionIndex); + this.writeWord(request, SaneAction.SET_VALUE); + this.writeWord(request, option.type === 'string' ? (value as string).length + 1 : option.size); + + // Write value based on type + if (option.type === 'bool' || option.type === 'int') { + this.writeWord(request, value as number); + } else if (option.type === 'fixed') { + this.writeWord(request, Math.round((value as number) * 65536)); + } else if (option.type === 'string') { + this.writeString(request, value as string); + } + + await this.sendRequest(request); + const response = await this.readResponse(); + + const status = this.readWord(response); + if (status !== SaneStatus.GOOD) { + throw new Error(`Failed to set option ${name}: ${STATUS_MESSAGES[status]}`); + } + } + + /** + * Get scan parameters (SANE_NET_GET_PARAMETERS) + */ + public async getParameters(): Promise { + const request = this.buildRequest(SaneRpc.GET_PARAMETERS); + this.writeWord(request, this.handle); + + await this.sendRequest(request); + const response = await this.readResponse(); + + const status = this.readWord(response); + if (status !== SaneStatus.GOOD) { + throw new Error(`Failed to get parameters: ${STATUS_MESSAGES[status]}`); + } + + const formatCode = this.readWord(response); + const format = this.mapFormat(formatCode); + + return { + format, + lastFrame: this.readWord(response) === 1, + bytesPerLine: this.readWord(response), + pixelsPerLine: this.readWord(response), + lines: this.readWord(response), + depth: this.readWord(response), + }; + } + + /** + * Start scanning (SANE_NET_START) + */ + public async start(): Promise<{ port: number; byteOrder: 'little' | 'big' }> { + const request = this.buildRequest(SaneRpc.START); + this.writeWord(request, this.handle); + + await this.sendRequest(request); + const response = await this.readResponse(); + + const status = this.readWord(response); + if (status !== SaneStatus.GOOD) { + throw new Error(`Failed to start scan: ${STATUS_MESSAGES[status]}`); + } + + const port = this.readWord(response); + const byteOrder = this.readWord(response) === 0x1234 ? 'little' : 'big'; + this.readString(response); // resource + + return { port, byteOrder }; + } + + /** + * Cancel scanning (SANE_NET_CANCEL) + */ + public async cancel(): Promise { + const request = this.buildRequest(SaneRpc.CANCEL); + this.writeWord(request, this.handle); + + await this.sendRequest(request); + await this.readResponse(); + } + + /** + * Perform a complete scan + */ + public async scan(options: IScanOptions): Promise { + // Configure scan options + await this.configureOptions(options); + + // Get parameters + const params = await this.getParameters(); + + // Start scan and get data port + const { port, byteOrder } = await this.start(); + + // Connect to data port and read image data + const imageData = await this.readImageData(port, params, byteOrder); + + return { + data: imageData, + format: options.format ?? 'png', + width: params.pixelsPerLine, + height: params.lines, + resolution: options.resolution ?? 300, + colorMode: options.colorMode ?? 'color', + mimeType: `image/${options.format ?? 'png'}`, + }; + } + + /** + * Configure scan options based on IScanOptions + */ + private async configureOptions(options: IScanOptions): Promise { + // Set resolution + if (options.resolution) { + const resOption = this.options.find((o) => o.name === 'resolution'); + if (resOption) { + await this.setOption('resolution', options.resolution); + } + } + + // Set color mode + if (options.colorMode) { + const modeOption = this.options.find((o) => o.name === 'mode'); + if (modeOption) { + const modeValue = this.mapColorMode(options.colorMode); + await this.setOption('mode', modeValue); + } + } + + // Set scan area + if (options.area) { + const tlxOption = this.options.find((o) => o.name === 'tl-x'); + const tlyOption = this.options.find((o) => o.name === 'tl-y'); + const brxOption = this.options.find((o) => o.name === 'br-x'); + const bryOption = this.options.find((o) => o.name === 'br-y'); + + if (tlxOption) await this.setOption('tl-x', options.area.x); + if (tlyOption) await this.setOption('tl-y', options.area.y); + if (brxOption) await this.setOption('br-x', options.area.x + options.area.width); + if (bryOption) await this.setOption('br-y', options.area.y + options.area.height); + } + + // Set source + if (options.source) { + const sourceOption = this.options.find((o) => o.name === 'source'); + if (sourceOption) { + await this.setOption('source', options.source); + } + } + } + + /** + * Read image data from data port + */ + private async readImageData( + port: number, + params: ISaneParameters, + _byteOrder: 'little' | 'big' + ): Promise { + return new Promise((resolve, reject) => { + const dataSocket = plugins.net.createConnection( + { host: this.address, port }, + () => { + const chunks: Buffer[] = []; + + dataSocket.on('data', (data: Buffer) => { + // Data format: 4 bytes length + data + // Read records until length is 0xFFFFFFFF (end marker) + let offset = 0; + while (offset < data.length) { + if (offset + 4 > data.length) { + break; + } + + const length = data.readUInt32BE(offset); + offset += 4; + + if (length === 0xffffffff) { + // End of data + dataSocket.destroy(); + resolve(Buffer.concat(chunks)); + return; + } + + if (offset + length > data.length) { + // Incomplete record, wait for more data + break; + } + + chunks.push(data.subarray(offset, offset + length)); + offset += length; + } + }); + + dataSocket.on('end', () => { + resolve(Buffer.concat(chunks)); + }); + + dataSocket.on('error', reject); + } + ); + + dataSocket.on('error', reject); + }); + } + + /** + * Map color mode to SANE mode string + */ + private mapColorMode(mode: TColorMode): string { + switch (mode) { + case 'color': + return 'Color'; + case 'grayscale': + return 'Gray'; + case 'blackwhite': + return 'Lineart'; + default: + return 'Color'; + } + } + + /** + * Map SANE format code to string + */ + private mapFormat(code: number): ISaneParameters['format'] { + const formats: Record = { + 0: 'gray', + 1: 'rgb', + 2: 'red', + 3: 'green', + 4: 'blue', + }; + return formats[code] ?? 'gray'; + } + + /** + * Map value type code to string + */ + private mapValueType(code: number): ISaneOption['type'] { + const types: Record = { + [SaneValueType.BOOL]: 'bool', + [SaneValueType.INT]: 'int', + [SaneValueType.FIXED]: 'fixed', + [SaneValueType.STRING]: 'string', + [SaneValueType.BUTTON]: 'button', + [SaneValueType.GROUP]: 'group', + }; + return types[code] ?? 'int'; + } + + /** + * Map unit code to string + */ + private mapUnit(code: number): ISaneOption['unit'] { + const units: Record = { + [SaneUnit.NONE]: 'none', + [SaneUnit.PIXEL]: 'pixel', + [SaneUnit.BIT]: 'bit', + [SaneUnit.MM]: 'mm', + [SaneUnit.DPI]: 'dpi', + [SaneUnit.PERCENT]: 'percent', + [SaneUnit.MICROSECOND]: 'microsecond', + }; + return units[code] ?? 'none'; + } + + /** + * Map constraint type code to string + */ + private mapConstraintType(code: number): ISaneOption['constraintType'] { + const types: Record = { + [SaneConstraintType.NONE]: 'none', + [SaneConstraintType.RANGE]: 'range', + [SaneConstraintType.WORD_LIST]: 'word_list', + [SaneConstraintType.STRING_LIST]: 'string_list', + }; + return types[code] ?? 'none'; + } + + // ========================================================================= + // Low-level protocol helpers + // ========================================================================= + + private buildRequest(rpc: SaneRpc): Buffer[] { + const chunks: Buffer[] = []; + const header = Buffer.alloc(4); + header.writeUInt32BE(rpc, 0); + chunks.push(header); + return chunks; + } + + private writeWord(chunks: Buffer[], value: number): void { + const buf = Buffer.alloc(4); + buf.writeUInt32BE(value >>> 0, 0); + chunks.push(buf); + } + + private writeString(chunks: Buffer[], str: string): void { + const strBuf = Buffer.from(str + '\0', 'utf-8'); + this.writeWord(chunks, strBuf.length); + chunks.push(strBuf); + } + + private async sendRequest(chunks: Buffer[]): Promise { + if (!this.socket) { + throw new Error('Not connected'); + } + + const data = Buffer.concat(chunks); + return new Promise((resolve, reject) => { + this.socket!.write(data, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + } + + private async readResponse(): Promise<{ buffer: Buffer; offset: number }> { + // Wait for data + await this.waitForData(4); + + return { buffer: this.readBuffer, offset: 0 }; + } + + private async waitForData(minBytes: number): Promise { + const startTime = Date.now(); + const timeout = 30000; + + while (this.readBuffer.length < minBytes) { + if (Date.now() - startTime > timeout) { + throw new Error('Timeout waiting for SANE response'); + } + await plugins.smartdelay.delayFor(10); + } + } + + private readWord(response: { buffer: Buffer; offset: number }): number { + const value = response.buffer.readUInt32BE(response.offset); + response.offset += 4; + return value; + } + + private readString(response: { buffer: Buffer; offset: number }): string { + const length = this.readWord(response); + if (length === 0) { + return ''; + } + + const str = response.buffer.toString('utf-8', response.offset, response.offset + length - 1); + response.offset += length; + return str; + } +} diff --git a/ts/scanner/scanner.classes.scanner.ts b/ts/scanner/scanner.classes.scanner.ts new file mode 100644 index 0000000..b9ffee6 --- /dev/null +++ b/ts/scanner/scanner.classes.scanner.ts @@ -0,0 +1,370 @@ +import * as plugins from '../plugins.js'; +import { Device } from '../abstract/device.abstract.js'; +import { EsclProtocol } from './scanner.classes.esclprotocol.js'; +import { SaneProtocol } from './scanner.classes.saneprotocol.js'; +import type { + IScannerInfo, + IScannerCapabilities, + IScanOptions, + IScanResult, + TScannerProtocol, + TScanFormat, + TColorMode, + TScanSource, + IRetryOptions, +} from '../interfaces/index.js'; + +/** + * Unified Scanner class that abstracts over eSCL and SANE protocols + */ +export class Scanner extends Device { + public readonly protocol: TScannerProtocol; + public supportedFormats: TScanFormat[] = ['jpeg', 'png', 'pdf']; + public supportedResolutions: number[] = [75, 150, 300, 600]; + public supportedColorModes: TColorMode[] = ['color', 'grayscale', 'blackwhite']; + public supportedSources: TScanSource[] = ['flatbed']; + public hasAdf: boolean = false; + public hasDuplex: boolean = false; + public maxWidth: number = 215.9; // A4 width in mm + public maxHeight: number = 297; // A4 height in mm + + private esclClient: EsclProtocol | null = null; + private saneClient: SaneProtocol | null = null; + private deviceName: string = ''; + private isSecure: boolean = false; + + constructor( + info: IScannerInfo, + options?: { + deviceName?: string; + secure?: boolean; + retryOptions?: IRetryOptions; + } + ) { + super(info, options?.retryOptions); + this.protocol = info.protocol; + this.supportedFormats = info.supportedFormats; + this.supportedResolutions = info.supportedResolutions; + this.supportedColorModes = info.supportedColorModes; + this.supportedSources = info.supportedSources; + this.hasAdf = info.hasAdf; + this.hasDuplex = info.hasDuplex; + this.maxWidth = info.maxWidth ?? this.maxWidth; + this.maxHeight = info.maxHeight ?? this.maxHeight; + this.deviceName = options?.deviceName ?? ''; + this.isSecure = options?.secure ?? false; + } + + /** + * Create a Scanner from discovery info + */ + public static fromDiscovery( + discoveredDevice: { + id: string; + name: string; + address: string; + port: number; + protocol: TScannerProtocol | 'ipp'; + txtRecords: Record; + }, + retryOptions?: IRetryOptions + ): Scanner { + const protocol = discoveredDevice.protocol === 'ipp' ? 'escl' : discoveredDevice.protocol; + + // Parse capabilities from TXT records + const formats = Scanner.parseFormats(discoveredDevice.txtRecords); + const resolutions = Scanner.parseResolutions(discoveredDevice.txtRecords); + const colorModes = Scanner.parseColorModes(discoveredDevice.txtRecords); + const sources = Scanner.parseSources(discoveredDevice.txtRecords); + + const info: IScannerInfo = { + id: discoveredDevice.id, + name: discoveredDevice.name, + type: 'scanner', + address: discoveredDevice.address, + port: discoveredDevice.port, + status: 'online', + protocol: protocol, + supportedFormats: formats, + supportedResolutions: resolutions, + supportedColorModes: colorModes, + supportedSources: sources, + hasAdf: sources.includes('adf') || sources.includes('adf-duplex'), + hasDuplex: sources.includes('adf-duplex'), + manufacturer: discoveredDevice.txtRecords['usb_MFG'] || discoveredDevice.txtRecords['mfg'], + model: discoveredDevice.txtRecords['usb_MDL'] || discoveredDevice.txtRecords['mdl'] || discoveredDevice.txtRecords['ty'], + }; + + const isSecure = discoveredDevice.txtRecords['TLS'] === '1' || + discoveredDevice.protocol === 'escl' && discoveredDevice.port === 443; + + return new Scanner(info, { + secure: isSecure, + retryOptions, + }); + } + + /** + * Parse supported formats from TXT records + */ + private static parseFormats(txtRecords: Record): TScanFormat[] { + const formats: TScanFormat[] = []; + const pdl = txtRecords['pdl'] || txtRecords['DocumentFormat'] || ''; + + if (pdl.includes('jpeg') || pdl.includes('jpg')) formats.push('jpeg'); + if (pdl.includes('png')) formats.push('png'); + if (pdl.includes('pdf')) formats.push('pdf'); + + // Default to jpeg if nothing found + if (formats.length === 0) { + formats.push('jpeg', 'png'); + } + + return formats; + } + + /** + * Parse supported resolutions from TXT records + */ + private static parseResolutions(txtRecords: Record): number[] { + const rs = txtRecords['rs'] || ''; + const resolutions: number[] = []; + + // Try to parse comma-separated resolutions + const parts = rs.split(',').map((s) => parseInt(s.trim())).filter((n) => !isNaN(n) && n > 0); + if (parts.length > 0) { + return parts; + } + + // Default common resolutions + return [75, 150, 300, 600]; + } + + /** + * Parse color modes from TXT records + */ + private static parseColorModes(txtRecords: Record): TColorMode[] { + const cs = txtRecords['cs'] || txtRecords['ColorSpace'] || ''; + const modes: TColorMode[] = []; + + if (cs.includes('color') || cs.includes('RGB')) modes.push('color'); + if (cs.includes('gray') || cs.includes('grayscale')) modes.push('grayscale'); + if (cs.includes('binary') || cs.includes('bw')) modes.push('blackwhite'); + + // Default to color and grayscale + if (modes.length === 0) { + modes.push('color', 'grayscale'); + } + + return modes; + } + + /** + * Parse input sources from TXT records + */ + private static parseSources(txtRecords: Record): TScanSource[] { + const is = txtRecords['is'] || txtRecords['InputSource'] || ''; + const sources: TScanSource[] = []; + + if (is.includes('platen') || is.includes('flatbed') || is === '') { + sources.push('flatbed'); + } + if (is.includes('adf') || is.includes('feeder')) { + sources.push('adf'); + } + if (is.includes('duplex')) { + sources.push('adf-duplex'); + } + + // Default to flatbed + if (sources.length === 0) { + sources.push('flatbed'); + } + + return sources; + } + + /** + * Get scanner info + */ + public getScannerInfo(): IScannerInfo { + return { + ...this.getInfo(), + type: 'scanner', + protocol: this.protocol, + supportedFormats: this.supportedFormats, + supportedResolutions: this.supportedResolutions, + supportedColorModes: this.supportedColorModes, + supportedSources: this.supportedSources, + hasAdf: this.hasAdf, + hasDuplex: this.hasDuplex, + maxWidth: this.maxWidth, + maxHeight: this.maxHeight, + }; + } + + /** + * Get scanner capabilities + */ + public async getCapabilities(): Promise { + if (!this.isConnected) { + await this.connect(); + } + + if (this.protocol === 'escl' && this.esclClient) { + const caps = await this.esclClient.getCapabilities(); + + const platen = caps.platen; + return { + resolutions: platen?.supportedResolutions ?? this.supportedResolutions, + formats: this.supportedFormats, + colorModes: this.supportedColorModes, + sources: this.supportedSources, + maxWidth: platen ? platen.maxWidth / 300 * 25.4 : this.maxWidth, + maxHeight: platen ? platen.maxHeight / 300 * 25.4 : this.maxHeight, + minWidth: platen ? platen.minWidth / 300 * 25.4 : 0, + minHeight: platen ? platen.minHeight / 300 * 25.4 : 0, + }; + } + + // Return defaults for SANE (would need to query options) + return { + resolutions: this.supportedResolutions, + formats: this.supportedFormats, + colorModes: this.supportedColorModes, + sources: this.supportedSources, + maxWidth: this.maxWidth, + maxHeight: this.maxHeight, + minWidth: 0, + minHeight: 0, + }; + } + + /** + * Perform a scan + */ + public async scan(options?: IScanOptions): Promise { + if (!this.isConnected) { + await this.connect(); + } + + const scanOptions: IScanOptions = { + resolution: options?.resolution ?? 300, + format: options?.format ?? 'jpeg', + colorMode: options?.colorMode ?? 'color', + source: options?.source ?? 'flatbed', + area: options?.area, + intent: options?.intent ?? 'document', + quality: options?.quality ?? 85, + }; + + this.setStatus('busy'); + this.emit('scan:started', scanOptions); + + try { + let result: IScanResult; + + if (this.protocol === 'escl' && this.esclClient) { + result = await this.withRetry(() => this.esclClient!.scan(scanOptions)); + } else if (this.protocol === 'sane' && this.saneClient) { + result = await this.withRetry(() => this.saneClient!.scan(scanOptions)); + } else { + throw new Error(`No protocol client available for ${this.protocol}`); + } + + this.setStatus('online'); + this.emit('scan:completed', result); + return result; + } catch (error) { + this.setStatus('online'); + this.emit('scan:error', error); + throw error; + } + } + + /** + * Cancel an ongoing scan + */ + public async cancelScan(): Promise { + if (this.protocol === 'sane' && this.saneClient) { + await this.saneClient.cancel(); + } + // eSCL cancellation is handled via job deletion in the protocol + this.emit('scan:canceled'); + } + + /** + * Connect to the scanner + */ + protected async doConnect(): Promise { + if (this.protocol === 'escl') { + this.esclClient = new EsclProtocol(this.address, this.port, this.isSecure); + // Test connection by getting capabilities + await this.esclClient.getCapabilities(); + } else if (this.protocol === 'sane') { + this.saneClient = new SaneProtocol(this.address, this.port); + await this.saneClient.connect(); + + // Get available devices + const devices = await this.saneClient.getDevices(); + if (devices.length === 0) { + throw new Error('No SANE devices available'); + } + + // Open the first device or the specified one + const deviceToOpen = this.deviceName || devices[0].name; + await this.saneClient.open(deviceToOpen); + } else { + throw new Error(`Unsupported protocol: ${this.protocol}`); + } + } + + /** + * Disconnect from the scanner + */ + protected async doDisconnect(): Promise { + if (this.esclClient) { + this.esclClient = null; + } + + if (this.saneClient) { + await this.saneClient.disconnect(); + this.saneClient = null; + } + } + + /** + * Refresh scanner status + */ + public async refreshStatus(): Promise { + try { + if (this.protocol === 'escl' && this.esclClient) { + const status = await this.esclClient.getStatus(); + switch (status.state) { + case 'Idle': + this.setStatus('online'); + break; + case 'Processing': + this.setStatus('busy'); + break; + case 'Stopped': + case 'Testing': + this.setStatus('offline'); + break; + } + } else if (this.protocol === 'sane') { + // SANE doesn't have a direct status query + // Just check if we can still communicate + if (this.saneClient) { + await this.saneClient.getParameters(); + this.setStatus('online'); + } + } + } catch (error) { + this.setStatus('error'); + throw error; + } + } +} + +export { EsclProtocol, SaneProtocol }; diff --git a/ts/snmp/snmp.classes.snmpdevice.ts b/ts/snmp/snmp.classes.snmpdevice.ts new file mode 100644 index 0000000..16f034d --- /dev/null +++ b/ts/snmp/snmp.classes.snmpdevice.ts @@ -0,0 +1,271 @@ +import * as plugins from '../plugins.js'; +import { Device } from '../abstract/device.abstract.js'; +import { SnmpProtocol, SNMP_OIDS, type ISnmpOptions, type ISnmpVarbind } from './snmp.classes.snmpprotocol.js'; +import type { IDeviceInfo, IRetryOptions, TDeviceStatus } from '../interfaces/index.js'; + +/** + * SNMP device information + */ +export interface ISnmpDeviceInfo extends IDeviceInfo { + type: 'snmp'; + sysDescr: string; + sysObjectID: string; + sysUpTime: number; + sysContact?: string; + sysName?: string; + sysLocation?: string; +} + +/** + * SNMP Device class for generic SNMP-enabled devices + */ +export class SnmpDevice extends Device { + private protocol: SnmpProtocol | null = null; + private snmpOptions: ISnmpOptions; + + private _sysDescr: string = ''; + private _sysObjectID: string = ''; + private _sysUpTime: number = 0; + private _sysContact?: string; + private _sysName?: string; + private _sysLocation?: string; + + constructor( + info: IDeviceInfo, + snmpOptions?: ISnmpOptions, + retryOptions?: IRetryOptions + ) { + super(info, retryOptions); + this.snmpOptions = { port: info.port, ...snmpOptions }; + } + + // Getters for SNMP properties + public get sysDescr(): string { + return this._sysDescr; + } + + public get sysObjectID(): string { + return this._sysObjectID; + } + + public get sysUpTime(): number { + return this._sysUpTime; + } + + public get sysContact(): string | undefined { + return this._sysContact; + } + + public get sysName(): string | undefined { + return this._sysName; + } + + public get sysLocation(): string | undefined { + return this._sysLocation; + } + + /** + * Connect to the SNMP device + */ + protected async doConnect(): Promise { + this.protocol = new SnmpProtocol(this.address, this.snmpOptions); + + // Verify connection by fetching system info + const sysInfo = await this.protocol.getSystemInfo(); + + this._sysDescr = sysInfo.sysDescr; + this._sysObjectID = sysInfo.sysObjectID; + this._sysUpTime = sysInfo.sysUpTime; + this._sysContact = sysInfo.sysContact || undefined; + this._sysName = sysInfo.sysName || undefined; + this._sysLocation = sysInfo.sysLocation || undefined; + + // Update device name if sysName is available + if (sysInfo.sysName && !this.name.includes('SNMP Device')) { + // Keep custom name + } else if (sysInfo.sysName) { + (this as { name: string }).name = sysInfo.sysName; + } + } + + /** + * Disconnect from the SNMP device + */ + protected async doDisconnect(): Promise { + if (this.protocol) { + this.protocol.close(); + this.protocol = null; + } + } + + /** + * Refresh device status + */ + public async refreshStatus(): Promise { + if (!this.protocol) { + throw new Error('Not connected'); + } + + const sysInfo = await this.protocol.getSystemInfo(); + this._sysUpTime = sysInfo.sysUpTime; + this.emit('status:updated', this.getDeviceInfo()); + } + + /** + * Get a single OID value + */ + public async get(oid: string): Promise { + if (!this.protocol) { + throw new Error('Not connected'); + } + return this.protocol.get(oid); + } + + /** + * Get multiple OID values + */ + public async getMultiple(oids: string[]): Promise { + if (!this.protocol) { + throw new Error('Not connected'); + } + return this.protocol.getMultiple(oids); + } + + /** + * Get next OID in the MIB tree + */ + public async getNext(oid: string): Promise { + if (!this.protocol) { + throw new Error('Not connected'); + } + return this.protocol.getNext(oid); + } + + /** + * GETBULK operation for efficient table retrieval + */ + public async getBulk( + oids: string[], + nonRepeaters?: number, + maxRepetitions?: number + ): Promise { + if (!this.protocol) { + throw new Error('Not connected'); + } + return this.protocol.getBulk(oids, nonRepeaters, maxRepetitions); + } + + /** + * Walk a MIB tree + */ + public async walk(baseOid: string): Promise { + if (!this.protocol) { + throw new Error('Not connected'); + } + return this.protocol.walk(baseOid); + } + + /** + * Set an OID value + */ + public async set( + oid: string, + type: 'Integer' | 'OctetString' | 'ObjectIdentifier' | 'IpAddress', + value: unknown + ): Promise { + if (!this.protocol) { + throw new Error('Not connected'); + } + return this.protocol.set(oid, type, value); + } + + /** + * Get device information + */ + public getDeviceInfo(): ISnmpDeviceInfo { + return { + id: this.id, + name: this.name, + type: 'snmp', + address: this.address, + port: this.port, + status: this.status, + sysDescr: this._sysDescr, + sysObjectID: this._sysObjectID, + sysUpTime: this._sysUpTime, + sysContact: this._sysContact, + sysName: this._sysName, + sysLocation: this._sysLocation, + }; + } + + /** + * Create SnmpDevice from discovery data + */ + public static fromDiscovery( + data: { + id: string; + name: string; + address: string; + port?: number; + community?: string; + }, + retryOptions?: IRetryOptions + ): SnmpDevice { + const info: IDeviceInfo = { + id: data.id, + name: data.name, + type: 'snmp', + address: data.address, + port: data.port ?? 161, + status: 'unknown', + }; + return new SnmpDevice( + info, + { community: data.community ?? 'public' }, + retryOptions + ); + } + + /** + * Probe an IP address for SNMP device + */ + public static async probe( + address: string, + port: number = 161, + community: string = 'public', + timeout: number = 5000 + ): Promise { + const protocol = new SnmpProtocol(address, { + community, + port, + timeout, + retries: 0, + }); + + try { + const sysInfo = await protocol.getSystemInfo(); + + return { + id: `snmp:${address}:${port}`, + name: sysInfo.sysName || `SNMP Device at ${address}`, + type: 'snmp', + address, + port, + status: 'online', + sysDescr: sysInfo.sysDescr, + sysObjectID: sysInfo.sysObjectID, + sysUpTime: sysInfo.sysUpTime, + sysContact: sysInfo.sysContact || undefined, + sysName: sysInfo.sysName || undefined, + sysLocation: sysInfo.sysLocation || undefined, + }; + } catch { + return null; + } finally { + protocol.close(); + } + } +} + +export { SNMP_OIDS }; diff --git a/ts/snmp/snmp.classes.snmpprotocol.ts b/ts/snmp/snmp.classes.snmpprotocol.ts new file mode 100644 index 0000000..ab9fc00 --- /dev/null +++ b/ts/snmp/snmp.classes.snmpprotocol.ts @@ -0,0 +1,439 @@ +import * as plugins from '../plugins.js'; + +/** + * Common SNMP OIDs (Object Identifiers) + */ +export const SNMP_OIDS = { + // System MIB (RFC 1213) + sysDescr: '1.3.6.1.2.1.1.1.0', + sysObjectID: '1.3.6.1.2.1.1.2.0', + sysUpTime: '1.3.6.1.2.1.1.3.0', + sysContact: '1.3.6.1.2.1.1.4.0', + sysName: '1.3.6.1.2.1.1.5.0', + sysLocation: '1.3.6.1.2.1.1.6.0', + sysServices: '1.3.6.1.2.1.1.7.0', + + // IF-MIB - Interfaces + ifNumber: '1.3.6.1.2.1.2.1.0', + ifTable: '1.3.6.1.2.1.2.2', + + // Host resources + hrSystemUptime: '1.3.6.1.2.1.25.1.1.0', + hrMemorySize: '1.3.6.1.2.1.25.2.2.0', + + // UPS-MIB (RFC 1628) + upsIdentManufacturer: '1.3.6.1.2.1.33.1.1.1.0', + upsIdentModel: '1.3.6.1.2.1.33.1.1.2.0', + upsBatteryStatus: '1.3.6.1.2.1.33.1.2.1.0', + upsSecondsOnBattery: '1.3.6.1.2.1.33.1.2.2.0', + upsEstimatedMinutesRemaining: '1.3.6.1.2.1.33.1.2.3.0', + upsEstimatedChargeRemaining: '1.3.6.1.2.1.33.1.2.4.0', + upsBatteryVoltage: '1.3.6.1.2.1.33.1.2.5.0', + upsInputFrequency: '1.3.6.1.2.1.33.1.3.3.1.2', + upsInputVoltage: '1.3.6.1.2.1.33.1.3.3.1.3', + upsOutputSource: '1.3.6.1.2.1.33.1.4.1.0', + upsOutputFrequency: '1.3.6.1.2.1.33.1.4.2.0', + upsOutputVoltage: '1.3.6.1.2.1.33.1.4.4.1.2', + upsOutputCurrent: '1.3.6.1.2.1.33.1.4.4.1.3', + upsOutputPower: '1.3.6.1.2.1.33.1.4.4.1.4', + upsOutputPercentLoad: '1.3.6.1.2.1.33.1.4.4.1.5', + + // Printer MIB + prtGeneralPrinterName: '1.3.6.1.2.1.43.5.1.1.16.1', + prtMarkerSuppliesLevel: '1.3.6.1.2.1.43.11.1.1.9', + prtMarkerSuppliesMaxCapacity: '1.3.6.1.2.1.43.11.1.1.8', +}; + +/** + * SNMP value types + */ +export type TSnmpValueType = + | 'OctetString' + | 'Integer' + | 'Counter' + | 'Counter32' + | 'Counter64' + | 'Gauge' + | 'Gauge32' + | 'TimeTicks' + | 'IpAddress' + | 'ObjectIdentifier' + | 'Null' + | 'Opaque'; + +/** + * SNMP varbind (variable binding) + */ +export interface ISnmpVarbind { + oid: string; + type: TSnmpValueType; + value: unknown; +} + +/** + * SNMP session options + */ +export interface ISnmpOptions { + /** Community string (v1/v2c) or username (v3) */ + community?: string; + /** SNMP version: 1, 2 (v2c), or 3 */ + version?: 1 | 2 | 3; + /** Request timeout in milliseconds */ + timeout?: number; + /** Number of retries */ + retries?: number; + /** Port (default: 161) */ + port?: number; +} + +const DEFAULT_OPTIONS: Required = { + community: 'public', + version: 2, + timeout: 5000, + retries: 1, + port: 161, +}; + +/** + * SNMP Protocol handler using net-snmp + */ +export class SnmpProtocol { + private session: ReturnType | null = null; + private address: string; + private options: Required; + + constructor(address: string, options?: ISnmpOptions) { + this.address = address; + this.options = { ...DEFAULT_OPTIONS, ...options }; + } + + /** + * Create SNMP session + */ + private getSession(): ReturnType { + if (!this.session) { + const snmpVersion = + this.options.version === 1 + ? plugins.netSnmp.Version1 + : plugins.netSnmp.Version2c; + + this.session = plugins.netSnmp.createSession(this.address, this.options.community, { + port: this.options.port, + retries: this.options.retries, + timeout: this.options.timeout, + version: snmpVersion, + }); + } + return this.session; + } + + /** + * Close SNMP session + */ + public close(): void { + if (this.session) { + this.session.close(); + this.session = null; + } + } + + /** + * GET operation - retrieve a single OID value + */ + public async get(oid: string): Promise { + return new Promise((resolve, reject) => { + const session = this.getSession(); + + session.get([oid], (error: Error | null, varbinds: unknown[]) => { + if (error) { + reject(error); + return; + } + + if (varbinds.length === 0) { + reject(new Error(`No response for OID ${oid}`)); + return; + } + + const vb = varbinds[0] as { oid: string; type: number; value: unknown }; + + if (plugins.netSnmp.isVarbindError(vb)) { + reject(new Error(`SNMP error for ${oid}: ${plugins.netSnmp.varbindError(vb)}`)); + return; + } + + resolve(this.parseVarbind(vb)); + }); + }); + } + + /** + * GET operation - retrieve multiple OID values + */ + public async getMultiple(oids: string[]): Promise { + return new Promise((resolve, reject) => { + const session = this.getSession(); + + session.get(oids, (error: Error | null, varbinds: unknown[]) => { + if (error) { + reject(error); + return; + } + + const results: ISnmpVarbind[] = []; + for (const vb of varbinds) { + const varbind = vb as { oid: string; type: number; value: unknown }; + if (!plugins.netSnmp.isVarbindError(varbind)) { + results.push(this.parseVarbind(varbind)); + } + } + + resolve(results); + }); + }); + } + + /** + * GETNEXT operation - get the next OID in the MIB tree + */ + public async getNext(oid: string): Promise { + return new Promise((resolve, reject) => { + const session = this.getSession(); + + session.getNext([oid], (error: Error | null, varbinds: unknown[]) => { + if (error) { + reject(error); + return; + } + + if (varbinds.length === 0) { + reject(new Error(`No response for GETNEXT ${oid}`)); + return; + } + + const vb = varbinds[0] as { oid: string; type: number; value: unknown }; + + if (plugins.netSnmp.isVarbindError(vb)) { + reject(new Error(`SNMP error for ${oid}: ${plugins.netSnmp.varbindError(vb)}`)); + return; + } + + resolve(this.parseVarbind(vb)); + }); + }); + } + + /** + * GETBULK operation (v2c/v3 only) - efficient retrieval of table rows + */ + public async getBulk( + oids: string[], + nonRepeaters: number = 0, + maxRepetitions: number = 20 + ): Promise { + if (this.options.version === 1) { + throw new Error('GETBULK is not supported in SNMPv1'); + } + + return new Promise((resolve, reject) => { + const session = this.getSession(); + + session.getBulk(oids, nonRepeaters, maxRepetitions, (error: Error | null, varbinds: unknown[]) => { + if (error) { + reject(error); + return; + } + + const results: ISnmpVarbind[] = []; + for (const vb of varbinds) { + const varbind = vb as { oid: string; type: number; value: unknown }; + if (!plugins.netSnmp.isVarbindError(varbind)) { + results.push(this.parseVarbind(varbind)); + } + } + + resolve(results); + }); + }); + } + + /** + * Walk operation - retrieve all OIDs under a tree + */ + public async walk(baseOid: string): Promise { + return new Promise((resolve, reject) => { + const session = this.getSession(); + const results: ISnmpVarbind[] = []; + + session.walk( + baseOid, + 20, // maxRepetitions + (varbinds: unknown[]) => { + for (const vb of varbinds) { + const varbind = vb as { oid: string; type: number; value: unknown }; + if (!plugins.netSnmp.isVarbindError(varbind)) { + results.push(this.parseVarbind(varbind)); + } + } + }, + (error: Error | null) => { + if (error) { + reject(error); + return; + } + resolve(results); + } + ); + }); + } + + /** + * SET operation - set an OID value + */ + public async set(oid: string, type: TSnmpValueType, value: unknown): Promise { + return new Promise((resolve, reject) => { + const session = this.getSession(); + + const snmpType = this.getSnmpType(type); + const varbind = { + oid, + type: snmpType, + value, + }; + + session.set([varbind], (error: Error | null, varbinds: unknown[]) => { + if (error) { + reject(error); + return; + } + + if (varbinds.length === 0) { + reject(new Error(`No response for SET ${oid}`)); + return; + } + + const vb = varbinds[0] as { oid: string; type: number; value: unknown }; + + if (plugins.netSnmp.isVarbindError(vb)) { + reject(new Error(`SNMP error for ${oid}: ${plugins.netSnmp.varbindError(vb)}`)); + return; + } + + resolve(this.parseVarbind(vb)); + }); + }); + } + + /** + * Get system information + */ + public async getSystemInfo(): Promise<{ + sysDescr: string; + sysObjectID: string; + sysUpTime: number; + sysContact: string; + sysName: string; + sysLocation: string; + }> { + const oids = [ + SNMP_OIDS.sysDescr, + SNMP_OIDS.sysObjectID, + SNMP_OIDS.sysUpTime, + SNMP_OIDS.sysContact, + SNMP_OIDS.sysName, + SNMP_OIDS.sysLocation, + ]; + + const varbinds = await this.getMultiple(oids); + + const getValue = (oid: string): unknown => { + const vb = varbinds.find((v) => v.oid === oid); + return vb?.value; + }; + + return { + sysDescr: String(getValue(SNMP_OIDS.sysDescr) || ''), + sysObjectID: String(getValue(SNMP_OIDS.sysObjectID) || ''), + sysUpTime: Number(getValue(SNMP_OIDS.sysUpTime) || 0), + sysContact: String(getValue(SNMP_OIDS.sysContact) || ''), + sysName: String(getValue(SNMP_OIDS.sysName) || ''), + sysLocation: String(getValue(SNMP_OIDS.sysLocation) || ''), + }; + } + + /** + * Check if device is reachable via SNMP + */ + public async isReachable(): Promise { + try { + await this.get(SNMP_OIDS.sysDescr); + return true; + } catch { + return false; + } + } + + /** + * Parse varbind to our format + */ + private parseVarbind(vb: { oid: string; type: number; value: unknown }): ISnmpVarbind { + return { + oid: vb.oid, + type: this.getTypeName(vb.type), + value: this.parseValue(vb.type, vb.value), + }; + } + + /** + * Get type name from SNMP type number + */ + private getTypeName(type: number): TSnmpValueType { + const typeMap: Record = { + [plugins.netSnmp.ObjectType.OctetString]: 'OctetString', + [plugins.netSnmp.ObjectType.Integer]: 'Integer', + [plugins.netSnmp.ObjectType.Counter]: 'Counter', + [plugins.netSnmp.ObjectType.Counter32]: 'Counter32', + [plugins.netSnmp.ObjectType.Counter64]: 'Counter64', + [plugins.netSnmp.ObjectType.Gauge]: 'Gauge', + [plugins.netSnmp.ObjectType.Gauge32]: 'Gauge32', + [plugins.netSnmp.ObjectType.TimeTicks]: 'TimeTicks', + [plugins.netSnmp.ObjectType.IpAddress]: 'IpAddress', + [plugins.netSnmp.ObjectType.OID]: 'ObjectIdentifier', + [plugins.netSnmp.ObjectType.Null]: 'Null', + [plugins.netSnmp.ObjectType.Opaque]: 'Opaque', + }; + return typeMap[type] || 'OctetString'; + } + + /** + * Get SNMP type number from type name + */ + private getSnmpType(type: TSnmpValueType): number { + const typeMap: Record = { + OctetString: plugins.netSnmp.ObjectType.OctetString, + Integer: plugins.netSnmp.ObjectType.Integer, + Counter: plugins.netSnmp.ObjectType.Counter, + Counter32: plugins.netSnmp.ObjectType.Counter32, + Counter64: plugins.netSnmp.ObjectType.Counter64, + Gauge: plugins.netSnmp.ObjectType.Gauge, + Gauge32: plugins.netSnmp.ObjectType.Gauge32, + TimeTicks: plugins.netSnmp.ObjectType.TimeTicks, + IpAddress: plugins.netSnmp.ObjectType.IpAddress, + ObjectIdentifier: plugins.netSnmp.ObjectType.OID, + Null: plugins.netSnmp.ObjectType.Null, + Opaque: plugins.netSnmp.ObjectType.Opaque, + }; + return typeMap[type]; + } + + /** + * Parse value based on type + */ + private parseValue(type: number, value: unknown): unknown { + // OctetString - convert Buffer to string + if (type === plugins.netSnmp.ObjectType.OctetString && Buffer.isBuffer(value)) { + return value.toString('utf8'); + } + return value; + } +} diff --git a/ts/ups/ups.classes.nutprotocol.ts b/ts/ups/ups.classes.nutprotocol.ts new file mode 100644 index 0000000..b847c5d --- /dev/null +++ b/ts/ups/ups.classes.nutprotocol.ts @@ -0,0 +1,471 @@ +import * as plugins from '../plugins.js'; + +/** + * NUT Protocol variable definitions + */ +export const NUT_VARIABLES = { + // Device info + deviceMfr: 'device.mfr', + deviceModel: 'device.model', + deviceSerial: 'device.serial', + deviceType: 'device.type', + + // UPS status + upsStatus: 'ups.status', + upsAlarm: 'ups.alarm', + upsTime: 'ups.time', + upsLoad: 'ups.load', + upsTemperature: 'ups.temperature', + + // Battery + batteryCharge: 'battery.charge', + batteryRuntime: 'battery.runtime', + batteryVoltage: 'battery.voltage', + batteryVoltageNominal: 'battery.voltage.nominal', + batteryType: 'battery.type', + batteryDate: 'battery.date', + batteryTemperature: 'battery.temperature', + + // Input + inputVoltage: 'input.voltage', + inputVoltageNominal: 'input.voltage.nominal', + inputFrequency: 'input.frequency', + inputFrequencyNominal: 'input.frequency.nominal', + inputTransferHigh: 'input.transfer.high', + inputTransferLow: 'input.transfer.low', + + // Output + outputVoltage: 'output.voltage', + outputVoltageNominal: 'output.voltage.nominal', + outputFrequency: 'output.frequency', + outputCurrent: 'output.current', +}; + +/** + * NUT instant commands + */ +export const NUT_COMMANDS = { + testBatteryStart: 'test.battery.start', + testBatteryStartQuick: 'test.battery.start.quick', + testBatteryStartDeep: 'test.battery.start.deep', + testBatteryStop: 'test.battery.stop', + calibrateStart: 'calibrate.start', + calibrateStop: 'calibrate.stop', + shutdown: 'shutdown.return', + shutdownStayOff: 'shutdown.stayoff', + shutdownStop: 'shutdown.stop', + shutdownReboot: 'shutdown.reboot', + beeperEnable: 'beeper.enable', + beeperDisable: 'beeper.disable', + beeperMute: 'beeper.mute', + beeperToggle: 'beeper.toggle', + loadOff: 'load.off', + loadOn: 'load.on', +}; + +/** + * UPS status flags from NUT + */ +export type TNutStatusFlag = + | 'OL' // Online (on utility power) + | 'OB' // On battery + | 'LB' // Low battery + | 'HB' // High battery + | 'RB' // Replace battery + | 'CHRG' // Charging + | 'DISCHRG' // Discharging + | 'BYPASS' // On bypass + | 'CAL' // Calibrating + | 'OFF' // Offline + | 'OVER' // Overloaded + | 'TRIM' // Trimming voltage + | 'BOOST' // Boosting voltage + | 'FSD'; // Forced shutdown + +/** + * NUT UPS information + */ +export interface INutUpsInfo { + name: string; + description: string; +} + +/** + * NUT variable + */ +export interface INutVariable { + name: string; + value: string; +} + +/** + * NUT Protocol handler for Network UPS Tools + * TCP-based text protocol on port 3493 + */ +export class NutProtocol { + private socket: plugins.net.Socket | null = null; + private address: string; + private port: number; + private connected: boolean = false; + private responseBuffer: string = ''; + private responseResolver: ((value: string[]) => void) | null = null; + private responseRejecter: ((error: Error) => void) | null = null; + + constructor(address: string, port: number = 3493) { + this.address = address; + this.port = port; + } + + /** + * Connect to NUT server + */ + public async connect(): Promise { + if (this.connected) { + return; + } + + return new Promise((resolve, reject) => { + this.socket = new plugins.net.Socket(); + + const timeout = setTimeout(() => { + if (this.socket) { + this.socket.destroy(); + this.socket = null; + } + reject(new Error(`Connection timeout to ${this.address}:${this.port}`)); + }, 5000); + + this.socket.on('connect', () => { + clearTimeout(timeout); + this.connected = true; + resolve(); + }); + + this.socket.on('error', (err) => { + clearTimeout(timeout); + this.connected = false; + if (this.responseRejecter) { + this.responseRejecter(err); + this.responseRejecter = null; + this.responseResolver = null; + } + reject(err); + }); + + this.socket.on('data', (data) => { + this.handleData(data); + }); + + this.socket.on('close', () => { + this.connected = false; + if (this.responseRejecter) { + this.responseRejecter(new Error('Connection closed')); + this.responseRejecter = null; + this.responseResolver = null; + } + }); + + this.socket.connect(this.port, this.address); + }); + } + + /** + * Disconnect from NUT server + */ + public async disconnect(): Promise { + if (!this.connected || !this.socket) { + return; + } + + try { + await this.sendCommand('LOGOUT'); + } catch { + // Ignore logout errors + } + + this.socket.destroy(); + this.socket = null; + this.connected = false; + } + + /** + * Check if connected + */ + public get isConnected(): boolean { + return this.connected; + } + + /** + * Handle incoming data + */ + private handleData(data: Buffer): void { + this.responseBuffer += data.toString(); + + // Check for complete response (ends with newline) + const lines = this.responseBuffer.split('\n'); + + // Check if we have a complete response + if (this.responseBuffer.endsWith('\n')) { + const responseLines = lines.filter((l) => l.trim().length > 0); + this.responseBuffer = ''; + + if (this.responseResolver) { + this.responseResolver(responseLines); + this.responseResolver = null; + this.responseRejecter = null; + } + } + } + + /** + * Send command and get response + */ + private async sendCommand(command: string): Promise { + if (!this.socket || !this.connected) { + throw new Error('Not connected to NUT server'); + } + + return new Promise((resolve, reject) => { + this.responseResolver = resolve; + this.responseRejecter = reject; + + const timeout = setTimeout(() => { + this.responseResolver = null; + this.responseRejecter = null; + reject(new Error(`Command timeout: ${command}`)); + }, 10000); + + this.responseResolver = (lines) => { + clearTimeout(timeout); + resolve(lines); + }; + + this.responseRejecter = (err) => { + clearTimeout(timeout); + reject(err); + }; + + this.socket!.write(`${command}\n`); + }); + } + + /** + * List available UPS devices + */ + public async listUps(): Promise { + await this.ensureConnected(); + const response = await this.sendCommand('LIST UPS'); + + const upsList: INutUpsInfo[] = []; + + for (const line of response) { + // Format: UPS "" + const match = line.match(/^UPS\s+(\S+)\s+"([^"]*)"/); + if (match) { + upsList.push({ + name: match[1], + description: match[2], + }); + } + } + + return upsList; + } + + /** + * Get all variables for a UPS + */ + public async listVariables(upsName: string): Promise { + await this.ensureConnected(); + const response = await this.sendCommand(`LIST VAR ${upsName}`); + + const variables: INutVariable[] = []; + + for (const line of response) { + // Format: VAR "" + const match = line.match(/^VAR\s+\S+\s+(\S+)\s+"([^"]*)"/); + if (match) { + variables.push({ + name: match[1], + value: match[2], + }); + } + } + + return variables; + } + + /** + * Get a specific variable value + */ + public async getVariable(upsName: string, varName: string): Promise { + await this.ensureConnected(); + const response = await this.sendCommand(`GET VAR ${upsName} ${varName}`); + + for (const line of response) { + // Format: VAR "" + const match = line.match(/^VAR\s+\S+\s+\S+\s+"([^"]*)"/); + if (match) { + return match[1]; + } + // Handle error responses + if (line.startsWith('ERR')) { + return null; + } + } + + return null; + } + + /** + * Get multiple variables at once + */ + public async getVariables(upsName: string, varNames: string[]): Promise> { + const results = new Map(); + + for (const varName of varNames) { + const value = await this.getVariable(upsName, varName); + if (value !== null) { + results.set(varName, value); + } + } + + return results; + } + + /** + * Execute an instant command + */ + public async runCommand(upsName: string, command: string): Promise { + await this.ensureConnected(); + const response = await this.sendCommand(`INSTCMD ${upsName} ${command}`); + + for (const line of response) { + if (line === 'OK') { + return true; + } + if (line.startsWith('ERR')) { + return false; + } + } + + return false; + } + + /** + * List available commands for a UPS + */ + public async listCommands(upsName: string): Promise { + await this.ensureConnected(); + const response = await this.sendCommand(`LIST CMD ${upsName}`); + + const commands: string[] = []; + + for (const line of response) { + // Format: CMD + const match = line.match(/^CMD\s+\S+\s+(\S+)/); + if (match) { + commands.push(match[1]); + } + } + + return commands; + } + + /** + * Parse UPS status string into flags + */ + public parseStatus(statusString: string): TNutStatusFlag[] { + return statusString.split(/\s+/).filter((s) => s.length > 0) as TNutStatusFlag[]; + } + + /** + * Get comprehensive UPS status + */ + public async getUpsStatus(upsName: string): Promise<{ + status: TNutStatusFlag[]; + batteryCharge: number; + batteryRuntime: number; + inputVoltage: number; + outputVoltage: number; + load: number; + }> { + const vars = await this.getVariables(upsName, [ + NUT_VARIABLES.upsStatus, + NUT_VARIABLES.batteryCharge, + NUT_VARIABLES.batteryRuntime, + NUT_VARIABLES.inputVoltage, + NUT_VARIABLES.outputVoltage, + NUT_VARIABLES.upsLoad, + ]); + + return { + status: this.parseStatus(vars.get(NUT_VARIABLES.upsStatus) || ''), + batteryCharge: parseFloat(vars.get(NUT_VARIABLES.batteryCharge) || '0'), + batteryRuntime: parseFloat(vars.get(NUT_VARIABLES.batteryRuntime) || '0'), + inputVoltage: parseFloat(vars.get(NUT_VARIABLES.inputVoltage) || '0'), + outputVoltage: parseFloat(vars.get(NUT_VARIABLES.outputVoltage) || '0'), + load: parseFloat(vars.get(NUT_VARIABLES.upsLoad) || '0'), + }; + } + + /** + * Get device information + */ + public async getDeviceInfo(upsName: string): Promise<{ + manufacturer: string; + model: string; + serial: string; + type: string; + }> { + const vars = await this.getVariables(upsName, [ + NUT_VARIABLES.deviceMfr, + NUT_VARIABLES.deviceModel, + NUT_VARIABLES.deviceSerial, + NUT_VARIABLES.deviceType, + ]); + + return { + manufacturer: vars.get(NUT_VARIABLES.deviceMfr) || '', + model: vars.get(NUT_VARIABLES.deviceModel) || '', + serial: vars.get(NUT_VARIABLES.deviceSerial) || '', + type: vars.get(NUT_VARIABLES.deviceType) || '', + }; + } + + /** + * Ensure connected before command + */ + private async ensureConnected(): Promise { + if (!this.connected) { + await this.connect(); + } + } + + /** + * Check if a NUT server is reachable + */ + public static async probe(address: string, port: number = 3493, timeout: number = 3000): Promise { + return new Promise((resolve) => { + const socket = new plugins.net.Socket(); + + const timer = setTimeout(() => { + socket.destroy(); + resolve(false); + }, timeout); + + socket.on('connect', () => { + clearTimeout(timer); + socket.destroy(); + resolve(true); + }); + + socket.on('error', () => { + clearTimeout(timer); + resolve(false); + }); + + socket.connect(port, address); + }); + } +} diff --git a/ts/ups/ups.classes.upssnmp.ts b/ts/ups/ups.classes.upssnmp.ts new file mode 100644 index 0000000..c00ad7e --- /dev/null +++ b/ts/ups/ups.classes.upssnmp.ts @@ -0,0 +1,377 @@ +import { SnmpProtocol, SNMP_OIDS, type ISnmpOptions } from '../snmp/snmp.classes.snmpprotocol.js'; + +/** + * Extended UPS-MIB OIDs (RFC 1628) + */ +export const UPS_SNMP_OIDS = { + // Identity + upsIdentManufacturer: '1.3.6.1.2.1.33.1.1.1.0', + upsIdentModel: '1.3.6.1.2.1.33.1.1.2.0', + upsIdentUPSSoftwareVersion: '1.3.6.1.2.1.33.1.1.3.0', + upsIdentAgentSoftwareVersion: '1.3.6.1.2.1.33.1.1.4.0', + upsIdentName: '1.3.6.1.2.1.33.1.1.5.0', + upsIdentAttachedDevices: '1.3.6.1.2.1.33.1.1.6.0', + + // Battery group + upsBatteryStatus: '1.3.6.1.2.1.33.1.2.1.0', + upsSecondsOnBattery: '1.3.6.1.2.1.33.1.2.2.0', + upsEstimatedMinutesRemaining: '1.3.6.1.2.1.33.1.2.3.0', + upsEstimatedChargeRemaining: '1.3.6.1.2.1.33.1.2.4.0', + upsBatteryVoltage: '1.3.6.1.2.1.33.1.2.5.0', + upsBatteryCurrent: '1.3.6.1.2.1.33.1.2.6.0', + upsBatteryTemperature: '1.3.6.1.2.1.33.1.2.7.0', + + // Input group + upsInputLineBads: '1.3.6.1.2.1.33.1.3.1.0', + upsInputNumLines: '1.3.6.1.2.1.33.1.3.2.0', + upsInputLineIndex: '1.3.6.1.2.1.33.1.3.3.1.1', + upsInputFrequency: '1.3.6.1.2.1.33.1.3.3.1.2', + upsInputVoltage: '1.3.6.1.2.1.33.1.3.3.1.3', + upsInputCurrent: '1.3.6.1.2.1.33.1.3.3.1.4', + upsInputTruePower: '1.3.6.1.2.1.33.1.3.3.1.5', + + // Output group + upsOutputSource: '1.3.6.1.2.1.33.1.4.1.0', + upsOutputFrequency: '1.3.6.1.2.1.33.1.4.2.0', + upsOutputNumLines: '1.3.6.1.2.1.33.1.4.3.0', + upsOutputLineIndex: '1.3.6.1.2.1.33.1.4.4.1.1', + upsOutputVoltage: '1.3.6.1.2.1.33.1.4.4.1.2', + upsOutputCurrent: '1.3.6.1.2.1.33.1.4.4.1.3', + upsOutputPower: '1.3.6.1.2.1.33.1.4.4.1.4', + upsOutputPercentLoad: '1.3.6.1.2.1.33.1.4.4.1.5', + + // Bypass group + upsBypassFrequency: '1.3.6.1.2.1.33.1.5.1.0', + upsBypassNumLines: '1.3.6.1.2.1.33.1.5.2.0', + + // Alarm group + upsAlarmsPresent: '1.3.6.1.2.1.33.1.6.1.0', + + // Test group + upsTestId: '1.3.6.1.2.1.33.1.7.1.0', + upsTestSpinLock: '1.3.6.1.2.1.33.1.7.2.0', + upsTestResultsSummary: '1.3.6.1.2.1.33.1.7.3.0', + upsTestResultsDetail: '1.3.6.1.2.1.33.1.7.4.0', + upsTestStartTime: '1.3.6.1.2.1.33.1.7.5.0', + upsTestElapsedTime: '1.3.6.1.2.1.33.1.7.6.0', + + // Control group + upsShutdownType: '1.3.6.1.2.1.33.1.8.1.0', + upsShutdownAfterDelay: '1.3.6.1.2.1.33.1.8.2.0', + upsStartupAfterDelay: '1.3.6.1.2.1.33.1.8.3.0', + upsRebootWithDuration: '1.3.6.1.2.1.33.1.8.4.0', + upsAutoRestart: '1.3.6.1.2.1.33.1.8.5.0', + + // Config group + upsConfigInputVoltage: '1.3.6.1.2.1.33.1.9.1.0', + upsConfigInputFreq: '1.3.6.1.2.1.33.1.9.2.0', + upsConfigOutputVoltage: '1.3.6.1.2.1.33.1.9.3.0', + upsConfigOutputFreq: '1.3.6.1.2.1.33.1.9.4.0', + upsConfigOutputVA: '1.3.6.1.2.1.33.1.9.5.0', + upsConfigOutputPower: '1.3.6.1.2.1.33.1.9.6.0', + upsConfigLowBattTime: '1.3.6.1.2.1.33.1.9.7.0', + upsConfigAudibleStatus: '1.3.6.1.2.1.33.1.9.8.0', + upsConfigLowVoltageTransferPoint: '1.3.6.1.2.1.33.1.9.9.0', + upsConfigHighVoltageTransferPoint: '1.3.6.1.2.1.33.1.9.10.0', +}; + +/** + * Battery status values from UPS-MIB + */ +export type TUpsBatteryStatus = + | 'unknown' + | 'batteryNormal' + | 'batteryLow' + | 'batteryDepleted'; + +/** + * Output source values from UPS-MIB + */ +export type TUpsOutputSource = + | 'other' + | 'none' + | 'normal' + | 'bypass' + | 'battery' + | 'booster' + | 'reducer'; + +/** + * Test results summary from UPS-MIB + */ +export type TUpsTestResult = + | 'donePass' + | 'doneWarning' + | 'doneError' + | 'aborted' + | 'inProgress' + | 'noTestsInitiated'; + +/** + * SNMP-based UPS status interface + */ +export interface IUpsSnmpStatus { + batteryStatus: TUpsBatteryStatus; + secondsOnBattery: number; + estimatedMinutesRemaining: number; + estimatedChargeRemaining: number; + batteryVoltage: number; + batteryTemperature: number; + outputSource: TUpsOutputSource; + outputFrequency: number; + outputVoltage: number; + outputCurrent: number; + outputPower: number; + outputPercentLoad: number; + inputFrequency: number; + inputVoltage: number; + alarmsPresent: number; +} + +/** + * UPS SNMP handler for querying UPS devices via SNMP + */ +export class UpsSnmpHandler { + private protocol: SnmpProtocol; + + constructor(address: string, options?: ISnmpOptions) { + this.protocol = new SnmpProtocol(address, options); + } + + /** + * Close SNMP session + */ + public close(): void { + this.protocol.close(); + } + + /** + * Get UPS identity information + */ + public async getIdentity(): Promise<{ + manufacturer: string; + model: string; + softwareVersion: string; + name: string; + }> { + const varbinds = await this.protocol.getMultiple([ + UPS_SNMP_OIDS.upsIdentManufacturer, + UPS_SNMP_OIDS.upsIdentModel, + UPS_SNMP_OIDS.upsIdentUPSSoftwareVersion, + UPS_SNMP_OIDS.upsIdentName, + ]); + + const getValue = (oid: string): string => { + const vb = varbinds.find((v) => v.oid === oid); + return String(vb?.value || ''); + }; + + return { + manufacturer: getValue(UPS_SNMP_OIDS.upsIdentManufacturer), + model: getValue(UPS_SNMP_OIDS.upsIdentModel), + softwareVersion: getValue(UPS_SNMP_OIDS.upsIdentUPSSoftwareVersion), + name: getValue(UPS_SNMP_OIDS.upsIdentName), + }; + } + + /** + * Get battery status + */ + public async getBatteryStatus(): Promise<{ + status: TUpsBatteryStatus; + secondsOnBattery: number; + estimatedMinutesRemaining: number; + estimatedChargeRemaining: number; + voltage: number; + temperature: number; + }> { + const varbinds = await this.protocol.getMultiple([ + UPS_SNMP_OIDS.upsBatteryStatus, + UPS_SNMP_OIDS.upsSecondsOnBattery, + UPS_SNMP_OIDS.upsEstimatedMinutesRemaining, + UPS_SNMP_OIDS.upsEstimatedChargeRemaining, + UPS_SNMP_OIDS.upsBatteryVoltage, + UPS_SNMP_OIDS.upsBatteryTemperature, + ]); + + const getValue = (oid: string): number => { + const vb = varbinds.find((v) => v.oid === oid); + return Number(vb?.value || 0); + }; + + const statusMap: Record = { + 1: 'unknown', + 2: 'batteryNormal', + 3: 'batteryLow', + 4: 'batteryDepleted', + }; + + return { + status: statusMap[getValue(UPS_SNMP_OIDS.upsBatteryStatus)] || 'unknown', + secondsOnBattery: getValue(UPS_SNMP_OIDS.upsSecondsOnBattery), + estimatedMinutesRemaining: getValue(UPS_SNMP_OIDS.upsEstimatedMinutesRemaining), + estimatedChargeRemaining: getValue(UPS_SNMP_OIDS.upsEstimatedChargeRemaining), + voltage: getValue(UPS_SNMP_OIDS.upsBatteryVoltage) / 10, // Typically in 0.1V units + temperature: getValue(UPS_SNMP_OIDS.upsBatteryTemperature), + }; + } + + /** + * Get input status + */ + public async getInputStatus(): Promise<{ + frequency: number; + voltage: number; + lineBads: number; + }> { + const varbinds = await this.protocol.getMultiple([ + UPS_SNMP_OIDS.upsInputFrequency + '.1', // Line 1 + UPS_SNMP_OIDS.upsInputVoltage + '.1', // Line 1 + UPS_SNMP_OIDS.upsInputLineBads, + ]); + + const getValue = (oid: string): number => { + const vb = varbinds.find((v) => v.oid === oid); + return Number(vb?.value || 0); + }; + + return { + frequency: getValue(UPS_SNMP_OIDS.upsInputFrequency + '.1') / 10, // 0.1 Hz units + voltage: getValue(UPS_SNMP_OIDS.upsInputVoltage + '.1'), + lineBads: getValue(UPS_SNMP_OIDS.upsInputLineBads), + }; + } + + /** + * Get output status + */ + public async getOutputStatus(): Promise<{ + source: TUpsOutputSource; + frequency: number; + voltage: number; + current: number; + power: number; + percentLoad: number; + }> { + const varbinds = await this.protocol.getMultiple([ + UPS_SNMP_OIDS.upsOutputSource, + UPS_SNMP_OIDS.upsOutputFrequency, + UPS_SNMP_OIDS.upsOutputVoltage + '.1', // Line 1 + UPS_SNMP_OIDS.upsOutputCurrent + '.1', // Line 1 + UPS_SNMP_OIDS.upsOutputPower + '.1', // Line 1 + UPS_SNMP_OIDS.upsOutputPercentLoad + '.1', // Line 1 + ]); + + const getValue = (oid: string): number => { + const vb = varbinds.find((v) => v.oid === oid); + return Number(vb?.value || 0); + }; + + const sourceMap: Record = { + 1: 'other', + 2: 'none', + 3: 'normal', + 4: 'bypass', + 5: 'battery', + 6: 'booster', + 7: 'reducer', + }; + + return { + source: sourceMap[getValue(UPS_SNMP_OIDS.upsOutputSource)] || 'other', + frequency: getValue(UPS_SNMP_OIDS.upsOutputFrequency) / 10, // 0.1 Hz units + voltage: getValue(UPS_SNMP_OIDS.upsOutputVoltage + '.1'), + current: getValue(UPS_SNMP_OIDS.upsOutputCurrent + '.1') / 10, // 0.1 A units + power: getValue(UPS_SNMP_OIDS.upsOutputPower + '.1'), + percentLoad: getValue(UPS_SNMP_OIDS.upsOutputPercentLoad + '.1'), + }; + } + + /** + * Get full UPS status + */ + public async getFullStatus(): Promise { + const [battery, input, output] = await Promise.all([ + this.getBatteryStatus(), + this.getInputStatus(), + this.getOutputStatus(), + ]); + + // Get alarms separately + let alarmsPresent = 0; + try { + const alarmVb = await this.protocol.get(UPS_SNMP_OIDS.upsAlarmsPresent); + alarmsPresent = Number(alarmVb.value || 0); + } catch { + // Ignore alarm fetch errors + } + + return { + batteryStatus: battery.status, + secondsOnBattery: battery.secondsOnBattery, + estimatedMinutesRemaining: battery.estimatedMinutesRemaining, + estimatedChargeRemaining: battery.estimatedChargeRemaining, + batteryVoltage: battery.voltage, + batteryTemperature: battery.temperature, + outputSource: output.source, + outputFrequency: output.frequency, + outputVoltage: output.voltage, + outputCurrent: output.current, + outputPower: output.power, + outputPercentLoad: output.percentLoad, + inputFrequency: input.frequency, + inputVoltage: input.voltage, + alarmsPresent, + }; + } + + /** + * Check if UPS-MIB is supported on device + */ + public async isUpsDevice(): Promise { + try { + const vb = await this.protocol.get(UPS_SNMP_OIDS.upsBatteryStatus); + return vb.value !== null && vb.value !== undefined; + } catch { + return false; + } + } + + /** + * Get configuration info + */ + public async getConfiguration(): Promise<{ + inputVoltage: number; + inputFrequency: number; + outputVoltage: number; + outputFrequency: number; + outputVA: number; + outputPower: number; + lowBatteryTime: number; + }> { + const varbinds = await this.protocol.getMultiple([ + UPS_SNMP_OIDS.upsConfigInputVoltage, + UPS_SNMP_OIDS.upsConfigInputFreq, + UPS_SNMP_OIDS.upsConfigOutputVoltage, + UPS_SNMP_OIDS.upsConfigOutputFreq, + UPS_SNMP_OIDS.upsConfigOutputVA, + UPS_SNMP_OIDS.upsConfigOutputPower, + UPS_SNMP_OIDS.upsConfigLowBattTime, + ]); + + const getValue = (oid: string): number => { + const vb = varbinds.find((v) => v.oid === oid); + return Number(vb?.value || 0); + }; + + return { + inputVoltage: getValue(UPS_SNMP_OIDS.upsConfigInputVoltage), + inputFrequency: getValue(UPS_SNMP_OIDS.upsConfigInputFreq) / 10, + outputVoltage: getValue(UPS_SNMP_OIDS.upsConfigOutputVoltage), + outputFrequency: getValue(UPS_SNMP_OIDS.upsConfigOutputFreq) / 10, + outputVA: getValue(UPS_SNMP_OIDS.upsConfigOutputVA), + outputPower: getValue(UPS_SNMP_OIDS.upsConfigOutputPower), + lowBatteryTime: getValue(UPS_SNMP_OIDS.upsConfigLowBattTime), + }; + } +}