This commit is contained in:
2026-01-09 07:14:39 +00:00
parent 95da37590c
commit 05e1f94c79
22 changed files with 6549 additions and 10 deletions

View File

@@ -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
View File

@@ -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: {}

View File

@@ -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();

View 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>;
}

View 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 };

View 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 };

View 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;
}
}

View 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();
}
}

View 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
View 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 };

View File

@@ -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
View 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;
};

View File

@@ -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 };

View 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),
};
}
}

View 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 };

View 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;
}
}
}

View 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;
}
}

View 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 };

View 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 };

View 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;
}
}

View 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);
});
}
}

View 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),
};
}
}