initial
This commit is contained in:
14
package.json
14
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"
|
||||
}
|
||||
}
|
||||
|
||||
295
pnpm-lock.yaml
generated
295
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
191
test/test.ts
191
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();
|
||||
|
||||
202
ts/abstract/device.abstract.ts
Normal file
202
ts/abstract/device.abstract.ts
Normal file
@@ -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<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return withRetry(fn, this.retryOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the device
|
||||
*/
|
||||
public async connect(): Promise<void> {
|
||||
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<void> {
|
||||
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<void>;
|
||||
|
||||
/**
|
||||
* Implementation-specific disconnect logic
|
||||
* Override in subclasses
|
||||
*/
|
||||
protected abstract doDisconnect(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Refresh device status
|
||||
* Override in subclasses
|
||||
*/
|
||||
public abstract refreshStatus(): Promise<void>;
|
||||
}
|
||||
477
ts/devicemanager.classes.devicemanager.ts
Normal file
477
ts/devicemanager.classes.devicemanager.ts
Normal file
@@ -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<IDeviceManagerOptions> = {
|
||||
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<string, Scanner> = new Map();
|
||||
private printers: Map<string, Printer> = new Map();
|
||||
private options: Required<IDeviceManagerOptions>;
|
||||
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<void> {
|
||||
await this.discovery.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop device discovery
|
||||
*/
|
||||
public async stopDiscovery(): Promise<void> {
|
||||
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<Scanner> {
|
||||
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<Printer> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
const disconnectPromises: Promise<void>[] = [];
|
||||
|
||||
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<void> {
|
||||
await this.stopDiscovery();
|
||||
await this.disconnectAll();
|
||||
this.scanners.clear();
|
||||
this.printers.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh status of all devices
|
||||
*/
|
||||
public async refreshAllStatus(): Promise<void> {
|
||||
const refreshPromises: Promise<void>[] = [];
|
||||
|
||||
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 };
|
||||
313
ts/discovery/discovery.classes.mdns.ts
Normal file
313
ts/discovery/discovery.classes.mdns.ts
Normal file
@@ -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<IDiscoveryOptions> = {
|
||||
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<string, IDiscoveredDevice> = new Map();
|
||||
private options: Required<IDiscoveryOptions>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, unknown> | undefined
|
||||
): Record<string, string> {
|
||||
const records: Record<string, string> = {};
|
||||
|
||||
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 };
|
||||
378
ts/discovery/discovery.classes.networkscanner.ts
Normal file
378
ts/discovery/discovery.classes.networkscanner.ts
Normal file
@@ -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<Omit<INetworkScanOptions, 'ipRange' | 'startIp' | 'endIp'>> = {
|
||||
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<T>(fn: () => Promise<T>): Promise<T> {
|
||||
while (this.running >= this.limit) {
|
||||
await new Promise<void>((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<INetworkScanResult[]> {
|
||||
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<void> {
|
||||
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<Omit<INetworkScanOptions, 'ipRange' | 'startIp' | 'endIp'>>
|
||||
): Promise<INetworkScanDevice[]> {
|
||||
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<void>[] = [];
|
||||
|
||||
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<number[]> {
|
||||
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<boolean> {
|
||||
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<INetworkScanDevice | null> {
|
||||
try {
|
||||
const ipp = new IppProtocol(ip, port);
|
||||
const attrs = await Promise.race([
|
||||
ipp.getAttributes(),
|
||||
new Promise<null>((_, 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<INetworkScanDevice | null> {
|
||||
try {
|
||||
const secure = port === 443;
|
||||
const escl = new EsclProtocol(ip, port, secure);
|
||||
|
||||
const caps = await Promise.race([
|
||||
escl.getCapabilities(),
|
||||
new Promise<null>((_, 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<INetworkScanDevice | null> {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
357
ts/discovery/discovery.classes.ssdp.ts
Normal file
357
ts/discovery/discovery.classes.ssdp.ts
Normal file
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<typeof plugins.nodeSsdp.Client> | null = null;
|
||||
private devices: Map<string, ISsdpDevice> = new Map();
|
||||
private running = false;
|
||||
private searchInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start SSDP discovery
|
||||
*/
|
||||
public async start(serviceTypes?: string[]): Promise<void> {
|
||||
if (this.running) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
this.client = new plugins.nodeSsdp.Client();
|
||||
|
||||
// Handle SSDP responses
|
||||
this.client.on('response', (headers: Record<string, string>, 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<void> {
|
||||
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<string, string>,
|
||||
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<void> {
|
||||
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}[^>]*>([^<]*)</${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]*?)</${tag}>`, 'i');
|
||||
const match = source.match(regex);
|
||||
return match?.[1] ?? '';
|
||||
};
|
||||
|
||||
// Parse services
|
||||
const services: ISsdpService[] = [];
|
||||
const serviceListBlock = getTagBlock('serviceList');
|
||||
const serviceMatches = serviceListBlock.match(/<service>[\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(/<icon>[\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<string, string | number> = {}
|
||||
): Promise<string> {
|
||||
// Build SOAP body
|
||||
let argsXml = '';
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
argsXml += `<${key}>${value}</${key}>`;
|
||||
}
|
||||
|
||||
const soapBody = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<u:${action} xmlns:u="${serviceType}">
|
||||
${argsXml}
|
||||
</u:${action}>
|
||||
</s:Body>
|
||||
</s:Envelope>`;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
171
ts/helpers/helpers.iprange.ts
Normal file
171
ts/helpers/helpers.iprange.ts
Normal file
@@ -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;
|
||||
}
|
||||
100
ts/helpers/helpers.retry.ts
Normal file
100
ts/helpers/helpers.retry.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { IRetryOptions } from '../interfaces/index.js';
|
||||
|
||||
const defaultRetryOptions: Required<IRetryOptions> = {
|
||||
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<IRetryOptions>
|
||||
): 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<T>(
|
||||
fn: () => Promise<T>,
|
||||
options?: IRetryOptions
|
||||
): Promise<T> {
|
||||
const opts: Required<IRetryOptions> = { ...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<TArgs extends unknown[], TResult>(
|
||||
fn: (...args: TArgs) => Promise<TResult>,
|
||||
options?: IRetryOptions
|
||||
): (...args: TArgs) => Promise<TResult> {
|
||||
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<T extends Record<string, unknown>>(
|
||||
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<unknown>,
|
||||
options
|
||||
) as T[typeof methodName];
|
||||
}
|
||||
|
||||
export { defaultRetryOptions };
|
||||
37
ts/index.ts
37
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';
|
||||
|
||||
366
ts/interfaces/index.ts
Normal file
366
ts/interfaces/index.ts
Normal file
@@ -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<string, string>;
|
||||
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;
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
329
ts/printer/printer.classes.ippprotocol.ts
Normal file
329
ts/printer/printer.classes.ippprotocol.ts
Normal file
@@ -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<typeof plugins.ipp.Printer>;
|
||||
|
||||
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<IPrinterCapabilities> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.printer.execute(
|
||||
'Get-Printer-Attributes',
|
||||
null,
|
||||
(err: Error | null, res: Record<string, unknown>) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const attrs = res['printer-attributes-tag'] as Record<string, unknown> || {};
|
||||
resolve(this.parseCapabilities(attrs));
|
||||
} catch (parseErr) {
|
||||
reject(parseErr);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a document
|
||||
*/
|
||||
public async print(data: Buffer, options?: IPrintOptions): Promise<IPrintJob> {
|
||||
const msg = this.buildPrintMessage(options);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.printer.execute(
|
||||
'Print-Job',
|
||||
{ ...msg, data },
|
||||
(err: Error | null, res: Record<string, unknown>) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const jobAttrs = res['job-attributes-tag'] as Record<string, unknown> || {};
|
||||
resolve(this.parseJobInfo(jobAttrs));
|
||||
} catch (parseErr) {
|
||||
reject(parseErr);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all jobs
|
||||
*/
|
||||
public async getJobs(): Promise<IPrintJob[]> {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>));
|
||||
}
|
||||
} else if (jobTags && typeof jobTags === 'object') {
|
||||
jobs.push(this.parseJobInfo(jobTags as Record<string, unknown>));
|
||||
}
|
||||
|
||||
resolve(jobs);
|
||||
} catch (parseErr) {
|
||||
reject(parseErr);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific job info
|
||||
*/
|
||||
public async getJobInfo(jobId: number): Promise<IPrintJob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.printer.execute(
|
||||
'Get-Job-Attributes',
|
||||
{
|
||||
'operation-attributes-tag': {
|
||||
'job-id': jobId,
|
||||
},
|
||||
},
|
||||
(err: Error | null, res: Record<string, unknown>) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const jobAttrs = res['job-attributes-tag'] as Record<string, unknown> || {};
|
||||
resolve(this.parseJobInfo(jobAttrs));
|
||||
} catch (parseErr) {
|
||||
reject(parseErr);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a job
|
||||
*/
|
||||
public async cancelJob(jobId: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.printer.execute(
|
||||
'Cancel-Job',
|
||||
{
|
||||
'operation-attributes-tag': {
|
||||
'job-id': jobId,
|
||||
},
|
||||
},
|
||||
(err: Error | null, _res: Record<string, unknown>) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if printer is available
|
||||
*/
|
||||
public async checkAvailability(): Promise<boolean> {
|
||||
try {
|
||||
await this.getAttributes();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build IPP print message from options
|
||||
*/
|
||||
private buildPrintMessage(options?: IPrintOptions): Record<string, unknown> {
|
||||
const operationAttrs: Record<string, unknown> = {
|
||||
'requesting-user-name': 'devicemanager',
|
||||
'job-name': options?.jobName ?? 'Print Job',
|
||||
'document-format': 'application/octet-stream',
|
||||
};
|
||||
|
||||
const jobAttrs: Record<string, unknown> = {};
|
||||
|
||||
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<string, number> = {
|
||||
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<string, unknown> = {
|
||||
'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<string, unknown>): 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<string, unknown>): 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<number, IPrintJob['state']> = {
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
255
ts/printer/printer.classes.printer.ts
Normal file
255
ts/printer/printer.classes.printer.ts
Normal file
@@ -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<string, string>;
|
||||
},
|
||||
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<IPrinterCapabilities> {
|
||||
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<IPrintJob> {
|
||||
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<IPrintJob[]> {
|
||||
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<IPrintJob> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
this.ippClient = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh printer status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
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 };
|
||||
423
ts/scanner/scanner.classes.esclprotocol.ts
Normal file
423
ts/scanner/scanner.classes.esclprotocol.ts
Normal file
@@ -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<TColorMode, string> = {
|
||||
color: 'RGB24',
|
||||
grayscale: 'Grayscale8',
|
||||
blackwhite: 'BlackAndWhite1',
|
||||
};
|
||||
|
||||
/**
|
||||
* Format MIME type mappings
|
||||
*/
|
||||
const FORMAT_MIME_MAP: Record<TScanFormat, string> = {
|
||||
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<string, string>; 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<string, string> = {};
|
||||
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<IEsclCapabilities> {
|
||||
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<IEsclScanStatus> {
|
||||
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<string> {
|
||||
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<IScanResult> {
|
||||
// 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<IScanResult> {
|
||||
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<void> {
|
||||
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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<scan:ScanSettings xmlns:scan="${NAMESPACES.scan}" xmlns:pwg="${NAMESPACES.pwg}">
|
||||
<pwg:Version>2.0</pwg:Version>
|
||||
<scan:Intent>${intent}</scan:Intent>
|
||||
<pwg:ScanRegions>
|
||||
<pwg:ScanRegion>`;
|
||||
|
||||
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 += `
|
||||
<pwg:XOffset>${toUnits(options.area.x)}</pwg:XOffset>
|
||||
<pwg:YOffset>${toUnits(options.area.y)}</pwg:YOffset>
|
||||
<pwg:Width>${toUnits(options.area.width)}</pwg:Width>
|
||||
<pwg:Height>${toUnits(options.area.height)}</pwg:Height>`;
|
||||
} else {
|
||||
// Full page (A4 default: 210x297mm)
|
||||
xml += `
|
||||
<pwg:XOffset>0</pwg:XOffset>
|
||||
<pwg:YOffset>0</pwg:YOffset>
|
||||
<pwg:Width>2480</pwg:Width>
|
||||
<pwg:Height>3508</pwg:Height>`;
|
||||
}
|
||||
|
||||
xml += `
|
||||
<pwg:ContentRegionUnits>escl:ThreeHundredthsOfInches</pwg:ContentRegionUnits>
|
||||
</pwg:ScanRegion>
|
||||
</pwg:ScanRegions>
|
||||
<scan:DocumentFormatExt>${format}</scan:DocumentFormatExt>
|
||||
<scan:XResolution>${resolution}</scan:XResolution>
|
||||
<scan:YResolution>${resolution}</scan:YResolution>
|
||||
<scan:ColorMode>${colorMode}</scan:ColorMode>
|
||||
<scan:InputSource>${source}</scan:InputSource>`;
|
||||
|
||||
if (options.format === 'jpeg' && options.quality) {
|
||||
xml += `
|
||||
<scan:CompressionFactor>${100 - options.quality}</scan:CompressionFactor>`;
|
||||
}
|
||||
|
||||
xml += `
|
||||
</scan:ScanSettings>`;
|
||||
|
||||
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<IScanResult> {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
694
ts/scanner/scanner.classes.saneprotocol.ts
Normal file
694
ts/scanner/scanner.classes.saneprotocol.ts
Normal file
@@ -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<number, string> = {
|
||||
[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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<ISaneDevice[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const request = this.buildRequest(SaneRpc.EXIT);
|
||||
await this.sendRequest(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get option descriptors (SANE_NET_GET_OPTION_DESCRIPTORS)
|
||||
*/
|
||||
private async getOptionDescriptors(): Promise<void> {
|
||||
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<void> {
|
||||
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<ISaneParameters> {
|
||||
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<void> {
|
||||
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<IScanResult> {
|
||||
// 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<void> {
|
||||
// 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<Buffer> {
|
||||
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<number, ISaneParameters['format']> = {
|
||||
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<number, ISaneOption['type']> = {
|
||||
[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<number, ISaneOption['unit']> = {
|
||||
[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<number, ISaneOption['constraintType']> = {
|
||||
[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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
370
ts/scanner/scanner.classes.scanner.ts
Normal file
370
ts/scanner/scanner.classes.scanner.ts
Normal file
@@ -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<string, string>;
|
||||
},
|
||||
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<string, string>): 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<string, string>): 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<string, string>): 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<string, string>): 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<IScannerCapabilities> {
|
||||
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<IScanResult> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (this.esclClient) {
|
||||
this.esclClient = null;
|
||||
}
|
||||
|
||||
if (this.saneClient) {
|
||||
await this.saneClient.disconnect();
|
||||
this.saneClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh scanner status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
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 };
|
||||
271
ts/snmp/snmp.classes.snmpdevice.ts
Normal file
271
ts/snmp/snmp.classes.snmpdevice.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
if (this.protocol) {
|
||||
this.protocol.close();
|
||||
this.protocol = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh device status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
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<ISnmpVarbind> {
|
||||
if (!this.protocol) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
return this.protocol.get(oid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple OID values
|
||||
*/
|
||||
public async getMultiple(oids: string[]): Promise<ISnmpVarbind[]> {
|
||||
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<ISnmpVarbind> {
|
||||
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<ISnmpVarbind[]> {
|
||||
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<ISnmpVarbind[]> {
|
||||
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<ISnmpVarbind> {
|
||||
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<ISnmpDeviceInfo | null> {
|
||||
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 };
|
||||
439
ts/snmp/snmp.classes.snmpprotocol.ts
Normal file
439
ts/snmp/snmp.classes.snmpprotocol.ts
Normal file
@@ -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<ISnmpOptions> = {
|
||||
community: 'public',
|
||||
version: 2,
|
||||
timeout: 5000,
|
||||
retries: 1,
|
||||
port: 161,
|
||||
};
|
||||
|
||||
/**
|
||||
* SNMP Protocol handler using net-snmp
|
||||
*/
|
||||
export class SnmpProtocol {
|
||||
private session: ReturnType<typeof plugins.netSnmp.createSession> | null = null;
|
||||
private address: string;
|
||||
private options: Required<ISnmpOptions>;
|
||||
|
||||
constructor(address: string, options?: ISnmpOptions) {
|
||||
this.address = address;
|
||||
this.options = { ...DEFAULT_OPTIONS, ...options };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SNMP session
|
||||
*/
|
||||
private getSession(): ReturnType<typeof plugins.netSnmp.createSession> {
|
||||
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<ISnmpVarbind> {
|
||||
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<ISnmpVarbind[]> {
|
||||
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<ISnmpVarbind> {
|
||||
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<ISnmpVarbind[]> {
|
||||
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<ISnmpVarbind[]> {
|
||||
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<ISnmpVarbind> {
|
||||
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<boolean> {
|
||||
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<number, TSnmpValueType> = {
|
||||
[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<TSnmpValueType, number> = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
471
ts/ups/ups.classes.nutprotocol.ts
Normal file
471
ts/ups/ups.classes.nutprotocol.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
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<INutUpsInfo[]> {
|
||||
await this.ensureConnected();
|
||||
const response = await this.sendCommand('LIST UPS');
|
||||
|
||||
const upsList: INutUpsInfo[] = [];
|
||||
|
||||
for (const line of response) {
|
||||
// Format: UPS <name> "<description>"
|
||||
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<INutVariable[]> {
|
||||
await this.ensureConnected();
|
||||
const response = await this.sendCommand(`LIST VAR ${upsName}`);
|
||||
|
||||
const variables: INutVariable[] = [];
|
||||
|
||||
for (const line of response) {
|
||||
// Format: VAR <ups> <name> "<value>"
|
||||
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<string | null> {
|
||||
await this.ensureConnected();
|
||||
const response = await this.sendCommand(`GET VAR ${upsName} ${varName}`);
|
||||
|
||||
for (const line of response) {
|
||||
// Format: VAR <ups> <name> "<value>"
|
||||
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<Map<string, string>> {
|
||||
const results = new Map<string, string>();
|
||||
|
||||
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<boolean> {
|
||||
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<string[]> {
|
||||
await this.ensureConnected();
|
||||
const response = await this.sendCommand(`LIST CMD ${upsName}`);
|
||||
|
||||
const commands: string[] = [];
|
||||
|
||||
for (const line of response) {
|
||||
// Format: CMD <ups> <command>
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
377
ts/ups/ups.classes.upssnmp.ts
Normal file
377
ts/ups/ups.classes.upssnmp.ts
Normal file
@@ -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<number, TUpsBatteryStatus> = {
|
||||
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<number, TUpsOutputSource> = {
|
||||
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<IUpsSnmpStatus> {
|
||||
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<boolean> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user