initial
This commit is contained in:
14
package.json
14
package.json
@@ -20,6 +20,18 @@
|
|||||||
"@types/node": "^25.0.3"
|
"@types/node": "^25.0.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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:
|
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':
|
'@push.rocks/smartpath':
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 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:
|
devDependencies:
|
||||||
'@git.zone/tsbuild':
|
'@git.zone/tsbuild':
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
@@ -535,6 +571,36 @@ packages:
|
|||||||
resolution: {integrity: sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==}
|
resolution: {integrity: sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==}
|
||||||
engines: {node: '>=12'}
|
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':
|
'@puppeteer/browsers@2.11.0':
|
||||||
resolution: {integrity: sha512-n6oQX6mYkG8TRPuPXmbPidkUbsSRalhmaaVAQxvH1IkQy63cwsH+kOjB3e4cpCDHg0aSvsiX9bQ4s2VB6mGWUQ==}
|
resolution: {integrity: sha512-n6oQX6mYkG8TRPuPXmbPidkUbsSRalhmaaVAQxvH1IkQy63cwsH+kOjB3e4cpCDHg0aSvsiX9bQ4s2VB6mGWUQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -616,6 +682,9 @@ packages:
|
|||||||
'@push.rocks/smarterror@2.0.1':
|
'@push.rocks/smarterror@2.0.1':
|
||||||
resolution: {integrity: sha512-iCcH1D8tlDJgMFsaJ6lhdOTKhbU0KoprNv9MRP9o7691QOx4JEDXiHtr/lNtxVo8BUtdb9CF6kazaknO9KuORA==}
|
resolution: {integrity: sha512-iCcH1D8tlDJgMFsaJ6lhdOTKhbU0KoprNv9MRP9o7691QOx4JEDXiHtr/lNtxVo8BUtdb9CF6kazaknO9KuORA==}
|
||||||
|
|
||||||
|
'@push.rocks/smartevent@2.0.5':
|
||||||
|
resolution: {integrity: sha512-aU1hEoiMv8qDs+b3ln6e6GseyqM8sSqkGxhNTteLM6ve5dmTofnAdQ/tXshYNUUg2kPqi4ohcuf1/iACwjXNHw==}
|
||||||
|
|
||||||
'@push.rocks/smartexit@1.1.0':
|
'@push.rocks/smartexit@1.1.0':
|
||||||
resolution: {integrity: sha512-GD8VLIbxQuwvhPXwK4eH162XAYSj+M3wGKWGNO3i1iY4bj8P3BARcgsWx6/ntN3aCo5ygWtrevrfD5iecYY2Ng==}
|
resolution: {integrity: sha512-GD8VLIbxQuwvhPXwK4eH162XAYSj+M3wGKWGNO3i1iY4bj8P3BARcgsWx6/ntN3aCo5ygWtrevrfD5iecYY2Ng==}
|
||||||
|
|
||||||
@@ -812,6 +881,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-bqorOaGXPOuiOSV81luTKrTghg4O4NBRD0zyv7TIqmrMGf4a0uoozaUMp1X8vQdZW+y0gTzUJP9wkzAE6Cci0g==}
|
resolution: {integrity: sha512-bqorOaGXPOuiOSV81luTKrTghg4O4NBRD0zyv7TIqmrMGf4a0uoozaUMp1X8vQdZW+y0gTzUJP9wkzAE6Cci0g==}
|
||||||
deprecated: This package has been deprecated in favour of the new package at @push.rocks/smartpromise
|
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':
|
'@pushrocks/smartstring@4.0.7':
|
||||||
resolution: {integrity: sha512-TxHSar7Cj29E+GOcIj4DeZKWCNVzHKdqnrBRqcBqLqmeYZvzFosLXpFKoaCJDq7MSxuPoCvu5woSdp9YmPXyog==}
|
resolution: {integrity: sha512-TxHSar7Cj29E+GOcIj4DeZKWCNVzHKdqnrBRqcBqLqmeYZvzFosLXpFKoaCJDq7MSxuPoCvu5woSdp9YmPXyog==}
|
||||||
deprecated: This package has been deprecated in favour of the new package at @push.rocks/smartstring
|
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':
|
'@types/jsonfile@6.1.4':
|
||||||
resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==}
|
resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==}
|
||||||
|
|
||||||
|
'@types/long@4.0.2':
|
||||||
|
resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==}
|
||||||
|
|
||||||
'@types/mdast@4.0.4':
|
'@types/mdast@4.0.4':
|
||||||
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
||||||
|
|
||||||
@@ -1419,6 +1495,9 @@ packages:
|
|||||||
argparse@2.0.1:
|
argparse@2.0.1:
|
||||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
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:
|
asn1js@3.0.7:
|
||||||
resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==}
|
resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -1430,6 +1509,9 @@ packages:
|
|||||||
async-mutex@0.5.0:
|
async-mutex@0.5.0:
|
||||||
resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==}
|
resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==}
|
||||||
|
|
||||||
|
async@2.6.4:
|
||||||
|
resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==}
|
||||||
|
|
||||||
asynckit@0.4.0:
|
asynckit@0.4.0:
|
||||||
resolution: {integrity: sha1-x57Zf380y48robyXkLzDZkdLS3k=}
|
resolution: {integrity: sha1-x57Zf380y48robyXkLzDZkdLS3k=}
|
||||||
|
|
||||||
@@ -1499,6 +1581,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==}
|
resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
|
bluebird@3.7.2:
|
||||||
|
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
|
||||||
|
|
||||||
bn.js@4.12.2:
|
bn.js@4.12.2:
|
||||||
resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==}
|
resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==}
|
||||||
|
|
||||||
@@ -1506,6 +1591,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
|
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
bonjour-service@1.3.0:
|
||||||
|
resolution: {integrity: sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==}
|
||||||
|
|
||||||
bowser@2.13.1:
|
bowser@2.13.1:
|
||||||
resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==}
|
resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==}
|
||||||
|
|
||||||
@@ -1569,6 +1657,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
|
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
|
||||||
engines: {node: '>=10'}
|
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:
|
ccount@2.0.1:
|
||||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||||
|
|
||||||
@@ -1679,6 +1773,22 @@ packages:
|
|||||||
dayjs@1.11.19:
|
dayjs@1.11.19:
|
||||||
resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==}
|
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:
|
debug@4.3.7:
|
||||||
resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==}
|
resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -2137,10 +2247,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
|
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
|
||||||
engines: {node: '>= 12'}
|
engines: {node: '>= 12'}
|
||||||
|
|
||||||
|
ip@1.1.9:
|
||||||
|
resolution: {integrity: sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==}
|
||||||
|
|
||||||
ipaddr.js@1.9.1:
|
ipaddr.js@1.9.1:
|
||||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||||
engines: {node: '>= 0.10'}
|
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:
|
is-arrayish@0.2.1:
|
||||||
resolution: {integrity: sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=}
|
resolution: {integrity: sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=}
|
||||||
|
|
||||||
@@ -2284,6 +2401,12 @@ packages:
|
|||||||
lodash.restparam@3.6.1:
|
lodash.restparam@3.6.1:
|
||||||
resolution: {integrity: sha1-k2pOMJ7zMKdkXtQUWYbIWuWyCAU=}
|
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:
|
longest-streak@3.1.0:
|
||||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||||
|
|
||||||
@@ -2557,9 +2680,16 @@ packages:
|
|||||||
socks:
|
socks:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
ms@2.0.0:
|
||||||
|
resolution: {integrity: sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=}
|
||||||
|
|
||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
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:
|
nanoid@4.0.2:
|
||||||
resolution: {integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==}
|
resolution: {integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==}
|
||||||
engines: {node: ^14 || ^16 || >=18}
|
engines: {node: ^14 || ^16 || >=18}
|
||||||
@@ -2573,6 +2703,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
net-snmp@3.26.0:
|
||||||
|
resolution: {integrity: sha512-sjL3tRHjSRHFfExXeY1kXwFcwlZnmGAJOMWK6MQT/7MMnJZaCM/n/U/03gLM1zOdUKI1+xUwlDAX/F4m8v86AA==}
|
||||||
|
|
||||||
netmask@2.0.2:
|
netmask@2.0.2:
|
||||||
resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==}
|
resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==}
|
||||||
engines: {node: '>= 0.4.0'}
|
engines: {node: '>= 0.4.0'}
|
||||||
@@ -2588,6 +2721,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==}
|
resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==}
|
||||||
engines: {node: '>= 6.13.0'}
|
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:
|
normalize-newline@4.1.0:
|
||||||
resolution: {integrity: sha512-ff4jKqMI8Xl50/4Mms/9jPobzAV/UK+kXG2XJ/7AqOmxIx8mqfqTIHYxuAnEgJ2AQeBbLnlbmZ5+38Y9A0w/YA==}
|
resolution: {integrity: sha512-ff4jKqMI8Xl50/4Mms/9jPobzAV/UK+kXG2XJ/7AqOmxIx8mqfqTIHYxuAnEgJ2AQeBbLnlbmZ5+38Y9A0w/YA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2755,6 +2892,10 @@ packages:
|
|||||||
proto-list@1.2.4:
|
proto-list@1.2.4:
|
||||||
resolution: {integrity: sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=}
|
resolution: {integrity: sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=}
|
||||||
|
|
||||||
|
protobufjs@6.11.4:
|
||||||
|
resolution: {integrity: sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
proxy-addr@2.0.7:
|
proxy-addr@2.0.7:
|
||||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
@@ -2898,6 +3039,10 @@ packages:
|
|||||||
safer-buffer@2.1.2:
|
safer-buffer@2.1.2:
|
||||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||||
|
|
||||||
|
sax@1.4.4:
|
||||||
|
resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==}
|
||||||
|
engines: {node: '>=11.0.0'}
|
||||||
|
|
||||||
semver@6.3.1:
|
semver@6.3.1:
|
||||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -2980,6 +3125,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==}
|
resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==}
|
||||||
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
|
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
|
||||||
|
|
||||||
|
sonos@1.14.2:
|
||||||
|
resolution: {integrity: sha512-E2haOiusny1mgfZvZxXCKOlnvrzoxdnTFXKhcVKPkpWGN1FYzjHUt9UZxQHzflnt48eVKpwGhX6d6miniNBfSQ==}
|
||||||
|
|
||||||
source-map@0.6.1:
|
source-map@0.6.1:
|
||||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -3075,6 +3223,9 @@ packages:
|
|||||||
through2@4.0.2:
|
through2@4.0.2:
|
||||||
resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==}
|
resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==}
|
||||||
|
|
||||||
|
thunky@1.1.0:
|
||||||
|
resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==}
|
||||||
|
|
||||||
tiny-worker@2.3.0:
|
tiny-worker@2.3.0:
|
||||||
resolution: {integrity: sha512-pJ70wq5EAqTAEl9IkGzA+fN0836rycEuz2Cn6yeZ6FRzlVS5IDOkFHpIoEsksPRQV34GDqXm65+OlnZqUSyK2g==}
|
resolution: {integrity: sha512-pJ70wq5EAqTAEl9IkGzA+fN0836rycEuz2Cn6yeZ6FRzlVS5IDOkFHpIoEsksPRQV34GDqXm65+OlnZqUSyK2g==}
|
||||||
|
|
||||||
@@ -3278,6 +3429,14 @@ packages:
|
|||||||
utf-8-validate:
|
utf-8-validate:
|
||||||
optional: true
|
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:
|
xmlhttprequest-ssl@2.1.2:
|
||||||
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
|
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
@@ -4345,6 +4504,29 @@ snapshots:
|
|||||||
'@pnpm/network.ca-file': 1.0.2
|
'@pnpm/network.ca-file': 1.0.2
|
||||||
config-chain: 1.1.13
|
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':
|
'@puppeteer/browsers@2.11.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
@@ -4622,6 +4804,11 @@ snapshots:
|
|||||||
clean-stack: 1.3.0
|
clean-stack: 1.3.0
|
||||||
make-error-cause: 2.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':
|
'@push.rocks/smartexit@1.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/lik': 6.2.2
|
'@push.rocks/lik': 6.2.2
|
||||||
@@ -5118,6 +5305,11 @@ snapshots:
|
|||||||
|
|
||||||
'@pushrocks/smartpromise@4.0.2': {}
|
'@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':
|
'@pushrocks/smartstring@4.0.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@pushrocks/isounique': 1.0.5
|
'@pushrocks/isounique': 1.0.5
|
||||||
@@ -5682,6 +5874,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 25.0.3
|
'@types/node': 25.0.3
|
||||||
|
|
||||||
|
'@types/long@4.0.2': {}
|
||||||
|
|
||||||
'@types/mdast@4.0.4':
|
'@types/mdast@4.0.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
@@ -5806,6 +6000,8 @@ snapshots:
|
|||||||
|
|
||||||
argparse@2.0.1: {}
|
argparse@2.0.1: {}
|
||||||
|
|
||||||
|
asn1-ber@1.2.2: {}
|
||||||
|
|
||||||
asn1js@3.0.7:
|
asn1js@3.0.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
pvtsutils: 1.3.6
|
pvtsutils: 1.3.6
|
||||||
@@ -5820,6 +6016,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
async@2.6.4:
|
||||||
|
dependencies:
|
||||||
|
lodash: 4.17.21
|
||||||
|
|
||||||
asynckit@0.4.0: {}
|
asynckit@0.4.0: {}
|
||||||
|
|
||||||
axios@1.13.2(debug@4.4.3):
|
axios@1.13.2(debug@4.4.3):
|
||||||
@@ -5879,6 +6079,8 @@ snapshots:
|
|||||||
|
|
||||||
basic-ftp@5.1.0: {}
|
basic-ftp@5.1.0: {}
|
||||||
|
|
||||||
|
bluebird@3.7.2: {}
|
||||||
|
|
||||||
bn.js@4.12.2: {}
|
bn.js@4.12.2: {}
|
||||||
|
|
||||||
body-parser@2.2.2:
|
body-parser@2.2.2:
|
||||||
@@ -5895,6 +6097,11 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
bonjour-service@1.3.0:
|
||||||
|
dependencies:
|
||||||
|
fast-deep-equal: 3.1.3
|
||||||
|
multicast-dns: 7.2.5
|
||||||
|
|
||||||
bowser@2.13.1: {}
|
bowser@2.13.1: {}
|
||||||
|
|
||||||
brace-expansion@1.1.12:
|
brace-expansion@1.1.12:
|
||||||
@@ -5966,6 +6173,20 @@ snapshots:
|
|||||||
|
|
||||||
camelcase@6.3.0: {}
|
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: {}
|
ccount@2.0.1: {}
|
||||||
|
|
||||||
character-entities-html4@2.1.0: {}
|
character-entities-html4@2.1.0: {}
|
||||||
@@ -6059,6 +6280,14 @@ snapshots:
|
|||||||
|
|
||||||
dayjs@1.11.19: {}
|
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:
|
debug@4.3.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
@@ -6630,8 +6859,12 @@ snapshots:
|
|||||||
|
|
||||||
ip-address@10.1.0: {}
|
ip-address@10.1.0: {}
|
||||||
|
|
||||||
|
ip@1.1.9: {}
|
||||||
|
|
||||||
ipaddr.js@1.9.1: {}
|
ipaddr.js@1.9.1: {}
|
||||||
|
|
||||||
|
ipp@2.0.1: {}
|
||||||
|
|
||||||
is-arrayish@0.2.1: {}
|
is-arrayish@0.2.1: {}
|
||||||
|
|
||||||
is-docker@2.2.1: {}
|
is-docker@2.2.1: {}
|
||||||
@@ -6761,6 +6994,10 @@ snapshots:
|
|||||||
|
|
||||||
lodash.restparam@3.6.1: {}
|
lodash.restparam@3.6.1: {}
|
||||||
|
|
||||||
|
lodash@4.17.21: {}
|
||||||
|
|
||||||
|
long@4.0.0: {}
|
||||||
|
|
||||||
longest-streak@3.1.0: {}
|
longest-streak@3.1.0: {}
|
||||||
|
|
||||||
lower-case@1.1.4: {}
|
lower-case@1.1.4: {}
|
||||||
@@ -7215,14 +7452,26 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
socks: 2.8.7
|
socks: 2.8.7
|
||||||
|
|
||||||
|
ms@2.0.0: {}
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
|
multicast-dns@7.2.5:
|
||||||
|
dependencies:
|
||||||
|
dns-packet: 5.6.1
|
||||||
|
thunky: 1.1.0
|
||||||
|
|
||||||
nanoid@4.0.2: {}
|
nanoid@4.0.2: {}
|
||||||
|
|
||||||
negotiator@0.6.3: {}
|
negotiator@0.6.3: {}
|
||||||
|
|
||||||
negotiator@1.0.0: {}
|
negotiator@1.0.0: {}
|
||||||
|
|
||||||
|
net-snmp@3.26.0:
|
||||||
|
dependencies:
|
||||||
|
asn1-ber: 1.2.2
|
||||||
|
smart-buffer: 4.2.0
|
||||||
|
|
||||||
netmask@2.0.2: {}
|
netmask@2.0.2: {}
|
||||||
|
|
||||||
new-find-package-json@2.0.0:
|
new-find-package-json@2.0.0:
|
||||||
@@ -7237,6 +7486,16 @@ snapshots:
|
|||||||
|
|
||||||
node-forge@1.3.3: {}
|
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:
|
normalize-newline@4.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
replace-buffer: 1.2.1
|
replace-buffer: 1.2.1
|
||||||
@@ -7384,6 +7643,22 @@ snapshots:
|
|||||||
|
|
||||||
proto-list@1.2.4: {}
|
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:
|
proxy-addr@2.0.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
forwarded: 0.2.0
|
forwarded: 0.2.0
|
||||||
@@ -7600,6 +7875,8 @@ snapshots:
|
|||||||
|
|
||||||
safer-buffer@2.1.2: {}
|
safer-buffer@2.1.2: {}
|
||||||
|
|
||||||
|
sax@1.4.4: {}
|
||||||
|
|
||||||
semver@6.3.1: {}
|
semver@6.3.1: {}
|
||||||
|
|
||||||
semver@7.7.3: {}
|
semver@7.7.3: {}
|
||||||
@@ -7734,6 +8011,15 @@ snapshots:
|
|||||||
ip-address: 10.1.0
|
ip-address: 10.1.0
|
||||||
smart-buffer: 4.2.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: {}
|
source-map@0.6.1: {}
|
||||||
|
|
||||||
space-separated-tokens@2.0.2: {}
|
space-separated-tokens@2.0.2: {}
|
||||||
@@ -7858,6 +8144,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
readable-stream: 3.6.2
|
readable-stream: 3.6.2
|
||||||
|
|
||||||
|
thunky@1.1.0: {}
|
||||||
|
|
||||||
tiny-worker@2.3.0:
|
tiny-worker@2.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
esm: 3.2.25
|
esm: 3.2.25
|
||||||
@@ -8022,6 +8310,13 @@ snapshots:
|
|||||||
|
|
||||||
ws@8.19.0: {}
|
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: {}
|
xmlhttprequest-ssl@2.1.2: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
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 { 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 () => {
|
// Test imports
|
||||||
console.log(devicemanager)
|
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
|
// native scope
|
||||||
import * as path from 'path';
|
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
|
// @push.rocks scope
|
||||||
import * as smartpath from '@push.rocks/smartpath';
|
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