@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.
- package/CHANGELOG.md +20 -0
- package/LICENSE +22 -0
- package/README.md +62 -2
- package/branding/Homebridge_x_Printer.png +0 -0
- package/branding/Homebridge_x_Printer.svg +1 -0
- package/branding/icon.png +0 -0
- package/config.schema.json +250 -0
- package/eslint.config.js +58 -0
- package/lib/accessories.js +333 -0
- package/lib/characteristics.js +95 -0
- package/lib/dashboard-server.js +134 -0
- package/lib/discovery.js +302 -0
- package/lib/energy.js +485 -0
- package/lib/fakegato/LICENSE +21 -0
- package/lib/fakegato/fakegato-history.js +833 -0
- package/lib/fakegato/fakegato-storage.js +155 -0
- package/lib/fakegato/fakegato-timer.js +128 -0
- package/lib/fakegato/uuid.js +28 -0
- package/lib/homebridge-ui/public/dashboard.css +207 -0
- package/lib/homebridge-ui/public/dashboard.html +33 -0
- package/lib/homebridge-ui/public/dashboard.js +421 -0
- package/lib/homebridge-ui/public/index.html +20 -0
- package/lib/homebridge-ui/public/settings.css +30 -0
- package/lib/homebridge-ui/public/settings.js +324 -0
- package/lib/homebridge-ui/server.js +137 -0
- package/lib/index.js +10 -0
- package/lib/ipp.js +352 -0
- package/lib/platform.js +335 -0
- package/lib/poller.js +187 -0
- package/package.json +56 -4
package/lib/discovery.js
ADDED
|
@@ -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 }
|