@homebridge-plugins/homebridge-printer 0.0.0-stage → 1.0.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Finds IPP printers on the local network using multicast DNS (Bonjour),
3
+ * the same way macOS and HP Smart find them. No dependencies.
4
+ */
5
+
6
+ import { Buffer } from 'node:buffer'
7
+ import dgram from 'node:dgram'
8
+
9
+ const MDNS_ADDR = '224.0.0.251'
10
+ const MDNS_PORT = 5353
11
+ const SERVICES = ['_ipp._tcp.local', '_ipps._tcp.local']
12
+ const TYPE = {
13
+ A: 1,
14
+ PTR: 12,
15
+ TXT: 16,
16
+ AAAA: 28,
17
+ SRV: 33,
18
+ }
19
+
20
+ function encodeName(name) {
21
+ const parts = name.split('.').filter(Boolean)
22
+ const bufs = parts.map((p) => {
23
+ const b = Buffer.from(p, 'utf8')
24
+ return Buffer.concat([
25
+ Buffer.from([b.length]),
26
+ b,
27
+ ])
28
+ })
29
+ return Buffer.concat([
30
+ ...bufs,
31
+ Buffer.from([0]),
32
+ ])
33
+ }
34
+
35
+ function buildQuery(names) {
36
+ const header = Buffer.alloc(12)
37
+ header.writeUInt16BE(names.length, 4) // QDCOUNT
38
+ const qs = names.map((n) => {
39
+ const tail = Buffer.alloc(4)
40
+ tail.writeUInt16BE(TYPE.PTR, 0)
41
+ tail.writeUInt16BE(1, 2) // class IN
42
+ return Buffer.concat([
43
+ encodeName(n),
44
+ tail,
45
+ ])
46
+ })
47
+ return Buffer.concat([header, ...qs])
48
+ }
49
+
50
+ function readName(buf, offset) {
51
+ const labels = []
52
+ let jumped = false
53
+ let end = offset
54
+ let guard = 0
55
+ while (offset < buf.length && guard++ < 128) {
56
+ const len = buf[offset]
57
+ if (len === 0) {
58
+ offset += 1
59
+ break
60
+ }
61
+ if ((len & 0xC0) === 0xC0) {
62
+ if (!jumped) {
63
+ end = offset + 2
64
+ }
65
+ offset = ((len & 0x3F) << 8) | buf[offset + 1]
66
+ jumped = true
67
+ continue
68
+ }
69
+ labels.push(buf.toString('utf8', offset + 1, offset + 1 + len))
70
+ offset += 1 + len
71
+ }
72
+ return {
73
+ name: labels.join('.'),
74
+ next: jumped ? end : offset,
75
+ }
76
+ }
77
+
78
+ function parsePacket(buf) {
79
+ if (buf.length < 12) {
80
+ return []
81
+ }
82
+ const qd = buf.readUInt16BE(4)
83
+ const total = buf.readUInt16BE(6) + buf.readUInt16BE(8) + buf.readUInt16BE(10)
84
+ let off = 12
85
+ for (let i = 0; i < qd; i++) {
86
+ off = readName(buf, off).next + 4
87
+ }
88
+ const records = []
89
+ for (let i = 0; i < total && off < buf.length; i++) {
90
+ const { name, next } = readName(buf, off)
91
+ off = next
92
+ if (off + 10 > buf.length) {
93
+ break
94
+ }
95
+ const type = buf.readUInt16BE(off)
96
+ const rdlen = buf.readUInt16BE(off + 8)
97
+ const rd = off + 10
98
+ off = rd + rdlen
99
+ const rec = {
100
+ name: name.toLowerCase(),
101
+ rawName: name,
102
+ type,
103
+ }
104
+ if (type === TYPE.PTR) {
105
+ rec.data = readName(buf, rd).name
106
+ } else if (type === TYPE.SRV) {
107
+ rec.data = {
108
+ port: buf.readUInt16BE(rd + 4),
109
+ target: readName(buf, rd + 6).name.toLowerCase(),
110
+ }
111
+ } else if (type === TYPE.A && rdlen === 4) {
112
+ rec.data = [
113
+ ...buf.subarray(rd, rd + 4),
114
+ ].join('.')
115
+ } else if (type === TYPE.TXT) {
116
+ const txt = {}
117
+ let p = rd
118
+ while (p < rd + rdlen) {
119
+ const l = buf[p]
120
+ const s = buf.toString('utf8', p + 1, p + 1 + l)
121
+ const eq = s.indexOf('=')
122
+ if (eq > 0) {
123
+ txt[s.slice(0, eq).toLowerCase()] = s.slice(eq + 1)
124
+ }
125
+ p += 1 + l
126
+ }
127
+ rec.data = txt
128
+ } else {
129
+ continue
130
+ }
131
+ records.push(rec)
132
+ }
133
+ return records
134
+ }
135
+
136
+ /** Turn a bag of mDNS records into printer entries. */
137
+ function assemble(records) {
138
+ const byName = (type, name) => records.filter(r => r.type === type && r.name === name.toLowerCase())
139
+ const printers = new Map()
140
+ for (const ptr of records.filter(r => r.type === TYPE.PTR && SERVICES.includes(r.name))) {
141
+ const instance = ptr.data
142
+ const srv = byName(TYPE.SRV, instance)[0]
143
+ if (!srv) {
144
+ continue
145
+ }
146
+ const txt = (byName(TYPE.TXT, instance)[0] || {}).data || {}
147
+ const ip = (byName(TYPE.A, srv.data.target)[0] || {}).data
148
+ const secure = ptr.name.startsWith('_ipps')
149
+ const host = ip || srv.data.target
150
+ const path = `/${String(txt.rp || 'ipp/print').replace(/^\/+/, '')}`
151
+ const label = instance.replace(/\._ipps?\._tcp\.local$/i, '')
152
+ const key = (txt.uuid || label).toLowerCase()
153
+ const existing = printers.get(key)
154
+ // Prefer plain IPP over IPPS when a printer advertises both (simpler, no certificate)
155
+ if (existing && !existing.secure) {
156
+ continue
157
+ }
158
+ printers.set(key, {
159
+ name: label,
160
+ model: txt.ty || txt.usb_mdl || null,
161
+ manufacturer: txt.usb_mfg || (/^hp\b/i.test(txt.ty || label)
162
+ ? 'HP'
163
+ : null),
164
+ host,
165
+ hostname: srv.data.target,
166
+ port: srv.data.port,
167
+ secure,
168
+ address: `${secure ? 'ipps' : 'ipp'}://${host}${srv.data.port === 631
169
+ ? ''
170
+ : `:${srv.data.port}`}${path}`,
171
+ })
172
+ }
173
+ return [
174
+ ...printers.values(),
175
+ ].sort((a, b) => a.name.localeCompare(b.name))
176
+ }
177
+
178
+ /**
179
+ * @param {{timeout?: number}} [opts]
180
+ * @returns {Promise<Array<{name, model, manufacturer, host, hostname, port, secure, address}>>} the printers found
181
+ */
182
+ function discoverPrinters(opts = {}) {
183
+ const timeout = opts.timeout || 3000
184
+ const query = buildQuery(SERVICES)
185
+ const records = []
186
+ const sockets = []
187
+
188
+ const listen = (sock) => {
189
+ sock.on('message', (msg) => {
190
+ try {
191
+ records.push(...parsePacket(msg))
192
+ } catch (_) {
193
+ /* ignore malformed packets */
194
+ }
195
+ })
196
+ sock.on('error', () => {})
197
+ sockets.push(sock)
198
+ }
199
+
200
+ return new Promise((resolve) => {
201
+ // 1) "legacy unicast" query from a random port: responders answer us directly
202
+ const uni = dgram.createSocket({ type: 'udp4' })
203
+ listen(uni)
204
+ uni.bind(0, () => {
205
+ const send = () => uni.send(query, MDNS_PORT, MDNS_ADDR, () => {})
206
+ send()
207
+ setTimeout(send, 700)
208
+ setTimeout(send, 1600)
209
+ })
210
+
211
+ // 2) also listen on the multicast group, in case responders only multicast
212
+ try {
213
+ const multi = dgram.createSocket({ type: 'udp4', reuseAddr: true })
214
+ listen(multi)
215
+ multi.bind(MDNS_PORT, () => {
216
+ try {
217
+ multi.addMembership(MDNS_ADDR)
218
+ multi.send(query, MDNS_PORT, MDNS_ADDR, () => {})
219
+ } catch (_) {
220
+ /* port shared with the OS responder may not allow this */
221
+ }
222
+ })
223
+ } catch (_) {
224
+ /* ignore */
225
+ }
226
+
227
+ setTimeout(() => {
228
+ for (const s of sockets) {
229
+ try {
230
+ s.close()
231
+ } catch (_) {
232
+ /* already closed */
233
+ }
234
+ }
235
+ resolve(assemble(records))
236
+ }, timeout)
237
+ })
238
+ }
239
+
240
+ /**
241
+ * Resolve "something.local" to an IPv4 address with a direct mDNS query.
242
+ * Used when the operating system can't resolve .local names itself
243
+ * (e.g. Linux without avahi/nss-mdns).
244
+ */
245
+ function resolveLocal(hostname, timeout = 2000) {
246
+ const name = String(hostname).toLowerCase().replace(/\.$/, '')
247
+ const header = Buffer.alloc(12)
248
+ header.writeUInt16BE(1, 4)
249
+ const tail = Buffer.alloc(4)
250
+ tail.writeUInt16BE(TYPE.A, 0)
251
+ tail.writeUInt16BE(1, 2)
252
+ const query = Buffer.concat([
253
+ header,
254
+ encodeName(name),
255
+ tail,
256
+ ])
257
+
258
+ return new Promise((resolve, reject) => {
259
+ const sock = dgram.createSocket({ type: 'udp4' })
260
+ let done = false
261
+ let timer = null
262
+ const finish = (err, ip) => {
263
+ if (done) {
264
+ return
265
+ }
266
+ done = true
267
+ clearTimeout(timer)
268
+ try {
269
+ sock.close()
270
+ } catch (_) {
271
+ /* closed */
272
+ }
273
+ if (err) {
274
+ reject(err)
275
+ } else {
276
+ resolve(ip)
277
+ }
278
+ }
279
+ timer = setTimeout(() => {
280
+ const err = new Error(`Could not find ${hostname} on the network`)
281
+ err.code = 'ENOTFOUND'
282
+ finish(err)
283
+ }, timeout)
284
+ sock.on('error', err => finish(err))
285
+ sock.on('message', (msg) => {
286
+ try {
287
+ const hit = parsePacket(msg).find(r => r.type === TYPE.A && r.name === name)
288
+ if (hit) {
289
+ finish(null, hit.data)
290
+ }
291
+ } catch (_) {
292
+ /* ignore */
293
+ }
294
+ })
295
+ sock.bind(0, () => {
296
+ sock.send(query, MDNS_PORT, MDNS_ADDR, () => {})
297
+ setTimeout(() => !done && sock.send(query, MDNS_PORT, MDNS_ADDR, () => {}), 600)
298
+ })
299
+ })
300
+ }
301
+
302
+ export { assemble, buildQuery, discoverPrinters, encodeName, parsePacket, resolveLocal, TYPE }