@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/ipp.js
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal, dependency-free IPP/2.0 client for Get-Printer-Attributes.
|
|
3
|
+
* Replaces @sealsystems/ipp, which is unmaintained.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Buffer } from 'node:buffer'
|
|
7
|
+
import dns from 'node:dns'
|
|
8
|
+
import http from 'node:http'
|
|
9
|
+
import https from 'node:https'
|
|
10
|
+
|
|
11
|
+
import { resolveLocal } from './discovery.js'
|
|
12
|
+
|
|
13
|
+
const localCache = new Map() // "printer.local" -> { ip, at }
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* dns.lookup replacement: uses the normal system lookup, and for ".local"
|
|
17
|
+
* names falls back to asking the network directly (mDNS) if that fails.
|
|
18
|
+
*/
|
|
19
|
+
function lookupWithMdns(hostname, options, callback) {
|
|
20
|
+
if (typeof options === 'function') {
|
|
21
|
+
callback = options
|
|
22
|
+
options = {}
|
|
23
|
+
}
|
|
24
|
+
const reply = (ip) => {
|
|
25
|
+
if (options && options.all) {
|
|
26
|
+
callback(null, [
|
|
27
|
+
{ address: ip, family: 4 },
|
|
28
|
+
])
|
|
29
|
+
} else {
|
|
30
|
+
callback(null, ip, 4)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
dns.lookup(hostname, options, (err, address, family) => {
|
|
34
|
+
if (!err || !/\.local\.?$/i.test(hostname)) {
|
|
35
|
+
return callback(err, address, family)
|
|
36
|
+
}
|
|
37
|
+
const hit = localCache.get(hostname.toLowerCase())
|
|
38
|
+
if (hit && Date.now() - hit.at < 10 * 60 * 1000) {
|
|
39
|
+
return reply(hit.ip)
|
|
40
|
+
}
|
|
41
|
+
resolveLocal(hostname).then(
|
|
42
|
+
(ip) => {
|
|
43
|
+
localCache.set(hostname.toLowerCase(), {
|
|
44
|
+
ip,
|
|
45
|
+
at: Date.now(),
|
|
46
|
+
})
|
|
47
|
+
reply(ip)
|
|
48
|
+
},
|
|
49
|
+
() => callback(err),
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const TAG = {
|
|
55
|
+
OPERATION: 0x01,
|
|
56
|
+
JOB: 0x02,
|
|
57
|
+
END: 0x03,
|
|
58
|
+
PRINTER: 0x04,
|
|
59
|
+
UNSUPPORTED_GROUP: 0x05,
|
|
60
|
+
INTEGER: 0x21,
|
|
61
|
+
BOOLEAN: 0x22,
|
|
62
|
+
ENUM: 0x23,
|
|
63
|
+
TEXT_LANG: 0x35,
|
|
64
|
+
NAME_LANG: 0x36,
|
|
65
|
+
KEYWORD: 0x44,
|
|
66
|
+
URI: 0x45,
|
|
67
|
+
CHARSET: 0x47,
|
|
68
|
+
LANGUAGE: 0x48,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const PRINTER_STATES = { 3: 'idle', 4: 'processing', 5: 'stopped' }
|
|
72
|
+
|
|
73
|
+
const STRING_TAGS = new Set([
|
|
74
|
+
0x41,
|
|
75
|
+
0x42,
|
|
76
|
+
0x44,
|
|
77
|
+
0x45,
|
|
78
|
+
0x46,
|
|
79
|
+
0x47,
|
|
80
|
+
0x48,
|
|
81
|
+
0x49,
|
|
82
|
+
0x4A,
|
|
83
|
+
])
|
|
84
|
+
|
|
85
|
+
let requestId = 1
|
|
86
|
+
|
|
87
|
+
function encodeAttr(tag, name, value) {
|
|
88
|
+
const nameBuf = Buffer.from(name, 'utf8')
|
|
89
|
+
let valBuf
|
|
90
|
+
if (typeof value === 'number') {
|
|
91
|
+
valBuf = Buffer.alloc(4)
|
|
92
|
+
valBuf.writeInt32BE(value)
|
|
93
|
+
} else {
|
|
94
|
+
valBuf = Buffer.from(String(value), 'utf8')
|
|
95
|
+
}
|
|
96
|
+
const head = Buffer.alloc(3)
|
|
97
|
+
head.writeUInt8(tag, 0)
|
|
98
|
+
head.writeUInt16BE(nameBuf.length, 1)
|
|
99
|
+
const len = Buffer.alloc(2)
|
|
100
|
+
len.writeUInt16BE(valBuf.length)
|
|
101
|
+
return Buffer.concat([head, nameBuf, len, valBuf])
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildGetPrinterAttributes(printerUri, requested) {
|
|
105
|
+
const parts = []
|
|
106
|
+
const header = Buffer.alloc(8)
|
|
107
|
+
header.writeUInt8(2, 0) // version 2.0
|
|
108
|
+
header.writeUInt8(0, 1)
|
|
109
|
+
header.writeUInt16BE(0x000B, 2) // Get-Printer-Attributes
|
|
110
|
+
header.writeInt32BE(requestId++ & 0x7FFFFFFF || 1, 4)
|
|
111
|
+
parts.push(header, Buffer.from([TAG.OPERATION]))
|
|
112
|
+
parts.push(encodeAttr(TAG.CHARSET, 'attributes-charset', 'utf-8'))
|
|
113
|
+
parts.push(encodeAttr(TAG.LANGUAGE, 'attributes-natural-language', 'en'))
|
|
114
|
+
parts.push(encodeAttr(TAG.URI, 'printer-uri', printerUri))
|
|
115
|
+
requested.forEach((attr, i) => {
|
|
116
|
+
parts.push(encodeAttr(TAG.KEYWORD, i === 0
|
|
117
|
+
? 'requested-attributes'
|
|
118
|
+
: '', attr))
|
|
119
|
+
})
|
|
120
|
+
parts.push(Buffer.from([TAG.END]))
|
|
121
|
+
return Buffer.concat(parts)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function decodeValue(tag, buf) {
|
|
125
|
+
if (tag === TAG.INTEGER || tag === TAG.ENUM) {
|
|
126
|
+
return buf.length === 4
|
|
127
|
+
? buf.readInt32BE(0)
|
|
128
|
+
: null
|
|
129
|
+
}
|
|
130
|
+
if (tag === TAG.BOOLEAN) {
|
|
131
|
+
return buf.length
|
|
132
|
+
? buf.readUInt8(0) !== 0
|
|
133
|
+
: false
|
|
134
|
+
}
|
|
135
|
+
if (tag === TAG.TEXT_LANG || tag === TAG.NAME_LANG) {
|
|
136
|
+
const langLen = buf.readUInt16BE(0)
|
|
137
|
+
const textLen = buf.readUInt16BE(2 + langLen)
|
|
138
|
+
return buf.toString('utf8', 4 + langLen, 4 + langLen + textLen)
|
|
139
|
+
}
|
|
140
|
+
if (STRING_TAGS.has(tag)) {
|
|
141
|
+
return buf.toString('utf8')
|
|
142
|
+
}
|
|
143
|
+
if (tag >= 0x10 && tag <= 0x1F) {
|
|
144
|
+
return null
|
|
145
|
+
} // out-of-band (unknown, no-value, ...)
|
|
146
|
+
return buf // octetString, dateTime, resolution, ranges ... (raw)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Parse an IPP response. Returns { version, statusCode, requestId, groups: { operation, printer, ... } }
|
|
151
|
+
* Multi-valued attributes become arrays. Collections are skipped (not needed here).
|
|
152
|
+
*/
|
|
153
|
+
function parseResponse(buf) {
|
|
154
|
+
if (!buf || buf.length < 9) {
|
|
155
|
+
throw new Error('IPP response too short')
|
|
156
|
+
}
|
|
157
|
+
const res = {
|
|
158
|
+
version: `${buf[0]}.${buf[1]}`,
|
|
159
|
+
statusCode: buf.readUInt16BE(2),
|
|
160
|
+
requestId: buf.readInt32BE(4),
|
|
161
|
+
groups: {},
|
|
162
|
+
}
|
|
163
|
+
const groupNames = {
|
|
164
|
+
1: 'operation',
|
|
165
|
+
2: 'job',
|
|
166
|
+
4: 'printer',
|
|
167
|
+
5: 'unsupported',
|
|
168
|
+
}
|
|
169
|
+
let off = 8
|
|
170
|
+
let group = null
|
|
171
|
+
let lastName = null
|
|
172
|
+
let collectionDepth = 0
|
|
173
|
+
|
|
174
|
+
while (off < buf.length) {
|
|
175
|
+
const tag = buf[off++]
|
|
176
|
+
if (tag === TAG.END) {
|
|
177
|
+
break
|
|
178
|
+
}
|
|
179
|
+
if (tag < 0x10) {
|
|
180
|
+
const gname = groupNames[tag] || `group${tag}`
|
|
181
|
+
group = res.groups[gname] = res.groups[gname] || {}
|
|
182
|
+
lastName = null
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
if (off + 2 > buf.length) {
|
|
186
|
+
break
|
|
187
|
+
}
|
|
188
|
+
const nameLen = buf.readUInt16BE(off)
|
|
189
|
+
off += 2
|
|
190
|
+
const name = buf.toString('utf8', off, off + nameLen)
|
|
191
|
+
off += nameLen
|
|
192
|
+
const valLen = buf.readUInt16BE(off)
|
|
193
|
+
off += 2
|
|
194
|
+
const raw = buf.subarray(off, off + valLen)
|
|
195
|
+
off += valLen
|
|
196
|
+
|
|
197
|
+
if (tag === 0x34) {
|
|
198
|
+
// begCollection
|
|
199
|
+
if (collectionDepth === 0 && name) {
|
|
200
|
+
lastName = name
|
|
201
|
+
}
|
|
202
|
+
collectionDepth++
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
if (tag === 0x37) {
|
|
206
|
+
collectionDepth = Math.max(0, collectionDepth - 1)
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
if (collectionDepth > 0 || !group) {
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const value = decodeValue(tag, raw)
|
|
214
|
+
if (name) {
|
|
215
|
+
lastName = name
|
|
216
|
+
group[name] = value
|
|
217
|
+
} else if (lastName) {
|
|
218
|
+
if (!Array.isArray(group[lastName])) {
|
|
219
|
+
group[lastName] = [group[lastName]]
|
|
220
|
+
}
|
|
221
|
+
group[lastName].push(value)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return res
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Normalises whatever the user typed into an http(s) endpoint + ipp printer-uri.
|
|
229
|
+
* Accepts: 192.168.1.20 | HPF92BAB.local | ipp://host/ipp/print | ipps://host | http://host:631/ipp/printer
|
|
230
|
+
*/
|
|
231
|
+
function normaliseAddress(address) {
|
|
232
|
+
let a = String(address || '').trim()
|
|
233
|
+
if (!/^[a-z]+:\/\//i.test(a)) {
|
|
234
|
+
a = `ipp://${a}`
|
|
235
|
+
}
|
|
236
|
+
const u = new URL(a)
|
|
237
|
+
const scheme = u.protocol.replace(':', '').toLowerCase()
|
|
238
|
+
const secure = scheme === 'ipps' || scheme === 'https'
|
|
239
|
+
const port = u.port
|
|
240
|
+
? Number(u.port)
|
|
241
|
+
: scheme === 'http'
|
|
242
|
+
? 80
|
|
243
|
+
: scheme === 'https'
|
|
244
|
+
? 443
|
|
245
|
+
: 631
|
|
246
|
+
const explicitPath = u.pathname && u.pathname !== '/'
|
|
247
|
+
? u.pathname
|
|
248
|
+
: null
|
|
249
|
+
return {
|
|
250
|
+
host: u.hostname,
|
|
251
|
+
port,
|
|
252
|
+
secure,
|
|
253
|
+
explicitPath,
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
class IppPrinter {
|
|
258
|
+
/**
|
|
259
|
+
* @param {string} address
|
|
260
|
+
* @param {{timeout?: number}} [opts]
|
|
261
|
+
*/
|
|
262
|
+
constructor(address, opts = {}) {
|
|
263
|
+
this.target = normaliseAddress(address)
|
|
264
|
+
this.timeout = opts.timeout || 8000
|
|
265
|
+
// HP printers use /ipp/print; some older models and CUPS use /ipp/printer or /ipp
|
|
266
|
+
this.paths = this.target.explicitPath
|
|
267
|
+
? [this.target.explicitPath]
|
|
268
|
+
: ['/ipp/print', '/ipp/printer', '/ipp']
|
|
269
|
+
this.resolvedPath = this.target.explicitPath || null
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
printerUri(path) {
|
|
273
|
+
const t = this.target
|
|
274
|
+
return `${t.secure ? 'ipps' : 'ipp'}://${t.host.includes(':')
|
|
275
|
+
? `[${t.host}]`
|
|
276
|
+
: t.host}:${t.port}${path}`
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
post(path, body) {
|
|
280
|
+
const t = this.target
|
|
281
|
+
const lib = t.secure ? https : http
|
|
282
|
+
return new Promise((resolve, reject) => {
|
|
283
|
+
const req = lib.request(
|
|
284
|
+
{
|
|
285
|
+
host: t.host,
|
|
286
|
+
port: t.port,
|
|
287
|
+
path,
|
|
288
|
+
method: 'POST',
|
|
289
|
+
headers: { 'Content-Type': 'application/ipp', 'Content-Length': body.length },
|
|
290
|
+
timeout: this.timeout,
|
|
291
|
+
lookup: lookupWithMdns,
|
|
292
|
+
// Printers ship self-signed certificates
|
|
293
|
+
rejectUnauthorized: false,
|
|
294
|
+
},
|
|
295
|
+
(res) => {
|
|
296
|
+
const chunks = []
|
|
297
|
+
res.on('data', c => chunks.push(c))
|
|
298
|
+
res.on('end', () => {
|
|
299
|
+
if (res.statusCode === 426 && !t.secure) {
|
|
300
|
+
// "Upgrade Required" - printer insists on TLS
|
|
301
|
+
t.secure = true
|
|
302
|
+
return this.post(path, body).then(resolve, reject)
|
|
303
|
+
}
|
|
304
|
+
if (res.statusCode !== 200) {
|
|
305
|
+
const err = new Error(`HTTP ${res.statusCode} from printer at ${path}`)
|
|
306
|
+
err.httpStatus = res.statusCode
|
|
307
|
+
return reject(err)
|
|
308
|
+
}
|
|
309
|
+
resolve(Buffer.concat(chunks))
|
|
310
|
+
})
|
|
311
|
+
res.on('error', reject)
|
|
312
|
+
},
|
|
313
|
+
)
|
|
314
|
+
req.on('timeout', () => {
|
|
315
|
+
const err = new Error('Request timed out')
|
|
316
|
+
err.code = 'ETIMEDOUT'
|
|
317
|
+
req.destroy(err)
|
|
318
|
+
})
|
|
319
|
+
req.on('error', reject)
|
|
320
|
+
req.end(body)
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async getAttributes(requested) {
|
|
325
|
+
const candidates = this.resolvedPath
|
|
326
|
+
? [this.resolvedPath]
|
|
327
|
+
: this.paths
|
|
328
|
+
let lastErr
|
|
329
|
+
for (const path of candidates) {
|
|
330
|
+
try {
|
|
331
|
+
const body = buildGetPrinterAttributes(this.printerUri(path), requested)
|
|
332
|
+
const res = parseResponse(await this.post(path, body))
|
|
333
|
+
if (res.statusCode > 0x00FF) {
|
|
334
|
+
const err = new Error(`IPP status 0x${res.statusCode.toString(16).padStart(4, '0')}`)
|
|
335
|
+
err.ippStatus = res.statusCode
|
|
336
|
+
throw err
|
|
337
|
+
}
|
|
338
|
+
this.resolvedPath = path
|
|
339
|
+
return res.groups.printer || {}
|
|
340
|
+
} catch (err) {
|
|
341
|
+
lastErr = err
|
|
342
|
+
// Network errors won't be fixed by trying another path
|
|
343
|
+
if (err.code && !err.httpStatus) {
|
|
344
|
+
throw err
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
throw lastErr
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export { buildGetPrinterAttributes, IppPrinter, lookupWithMdns, normaliseAddress, parseResponse, PRINTER_STATES }
|
package/lib/platform.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { OnlineSwitchAccessory, PrinterAccessory, PrintingSwitchAccessory } from './accessories.js'
|
|
6
|
+
import { createCharacteristics } from './characteristics.js'
|
|
7
|
+
import { DashboardServer } from './dashboard-server.js'
|
|
8
|
+
import { MatterEnergyPublisher, normaliseEnergyConfig } from './energy.js'
|
|
9
|
+
import eveService from './fakegato/fakegato-history.js'
|
|
10
|
+
import { normaliseAddress } from './ipp.js'
|
|
11
|
+
import { PrinterPoller } from './poller.js'
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url)
|
|
14
|
+
const pkg = require('../package.json')
|
|
15
|
+
|
|
16
|
+
const PLUGIN_NAME = '@homebridge-plugins/homebridge-printer'
|
|
17
|
+
const PLATFORM_NAME = 'Printer'
|
|
18
|
+
|
|
19
|
+
class PrinterPlatform {
|
|
20
|
+
constructor(log, config, api) {
|
|
21
|
+
this.log = log
|
|
22
|
+
this.config = config || {}
|
|
23
|
+
this.api = api
|
|
24
|
+
this.version = pkg.version
|
|
25
|
+
this.cached = new Map() // UUID -> PlatformAccessory
|
|
26
|
+
this.handlers = []
|
|
27
|
+
this.pollers = new Map() // printer id -> PrinterPoller
|
|
28
|
+
this.devices = this.normaliseConfig(this.config)
|
|
29
|
+
this.customChars = createCharacteristics(api.hap)
|
|
30
|
+
this.pluginName = PLUGIN_NAME
|
|
31
|
+
this.platformName = PLATFORM_NAME
|
|
32
|
+
this.energy = new MatterEnergyPublisher(this)
|
|
33
|
+
|
|
34
|
+
if (this.config.debug) {
|
|
35
|
+
// Show debug output without having to start Homebridge with -D
|
|
36
|
+
this.log = {
|
|
37
|
+
info: (...a) => log.info(...a),
|
|
38
|
+
warn: (...a) => log.warn(...a),
|
|
39
|
+
error: (...a) => log.error(...a),
|
|
40
|
+
success: (...a) => (log.success || log.info)(...a),
|
|
41
|
+
debug: (...a) => log.info('[debug]', ...a),
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
api.on('didFinishLaunching', () => this.didFinishLaunching())
|
|
46
|
+
api.on('shutdown', () => this.shutdown())
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
normaliseConfig(config) {
|
|
50
|
+
const list = Array.isArray(config.printers)
|
|
51
|
+
? config.printers
|
|
52
|
+
: Array.isArray(config.printer)
|
|
53
|
+
? config.printer
|
|
54
|
+
: []
|
|
55
|
+
const seen = new Set()
|
|
56
|
+
const out = []
|
|
57
|
+
list.forEach((p, i) => {
|
|
58
|
+
if (!p || !String(p.address || '').trim()) {
|
|
59
|
+
this.log.warn(`Printer ${i + 1} has no IP address or .local name yet - add one in the plugin settings.`)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
const address = String(p.address).trim()
|
|
63
|
+
try {
|
|
64
|
+
normaliseAddress(address)
|
|
65
|
+
} catch {
|
|
66
|
+
this.log.warn(`Printer ${i + 1}: "${address}" is not an address the plugin can use - check it in the plugin settings.`)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
// Name is optional: default to "Printer", "Printer 2", ...
|
|
70
|
+
let name = String(p.name || '').trim()
|
|
71
|
+
if (!name) {
|
|
72
|
+
name = 'Printer'
|
|
73
|
+
for (let n = 2; seen.has(name); n++) {
|
|
74
|
+
name = `Printer ${n}`
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (seen.has(name)) {
|
|
78
|
+
this.log.warn(
|
|
79
|
+
`${name}: two printers have the same name - give one of them a different name. Skipping the second.`,
|
|
80
|
+
)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
// the accessories' ids come from the name, so "Office" and "Office:online" would clash
|
|
84
|
+
const seeds = [name, `${name}:online`, `${name}:printing`]
|
|
85
|
+
if (seeds.some(seed => seen.has(`seed:${seed}`))) {
|
|
86
|
+
this.log.warn(`${name}: this name clashes with another printer's - give it a different name. Skipping it.`)
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
seen.add(name)
|
|
90
|
+
seeds.forEach(seed => seen.add(`seed:${seed}`))
|
|
91
|
+
// Older configs used one "switchType" setting; NONE / CHARACTERISTIC meant "no switch"
|
|
92
|
+
const legacyType = String(p.switchType || '').toUpperCase()
|
|
93
|
+
const legacyNoSwitch = legacyType === 'NONE' || legacyType === 'CHARACTERISTIC'
|
|
94
|
+
out.push({
|
|
95
|
+
id: this.api.hap.uuid.generate(`hp-printer:${name}`),
|
|
96
|
+
name,
|
|
97
|
+
printingSwitch: typeof p.printingSwitch === 'boolean'
|
|
98
|
+
? p.printingSwitch
|
|
99
|
+
: !legacyNoSwitch,
|
|
100
|
+
printingName: `${name} Printing`,
|
|
101
|
+
onlineSwitch: typeof p.onlineSwitch === 'boolean'
|
|
102
|
+
? p.onlineSwitch
|
|
103
|
+
: !legacyNoSwitch,
|
|
104
|
+
onlineName: `${name} Online`,
|
|
105
|
+
onlineCharacteristic: legacyType === 'CHARACTERISTIC',
|
|
106
|
+
energy: normaliseEnergyConfig(p),
|
|
107
|
+
address,
|
|
108
|
+
polling:
|
|
109
|
+
Number.isFinite(Number(p.polling)) && Number(p.polling) > 0
|
|
110
|
+
? Math.max(2, Math.round(Number(p.polling)))
|
|
111
|
+
: 5,
|
|
112
|
+
marker: p.marker !== false,
|
|
113
|
+
history: p.history !== false,
|
|
114
|
+
lowInkThreshold:
|
|
115
|
+
Number.isFinite(Number(p.lowInkThreshold)) && p.lowInkThreshold !== ''
|
|
116
|
+
? Math.min(99, Math.max(0, Number(p.lowInkThreshold)))
|
|
117
|
+
: 10,
|
|
118
|
+
manufacturer: p.manufacturer,
|
|
119
|
+
model: p.model,
|
|
120
|
+
serialNumber: p.serialNumber,
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
return out
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Homebridge 2 calls this for every Matter accessory restored from cache. */
|
|
127
|
+
configureMatterAccessory(accessory) {
|
|
128
|
+
this.energy.configureMatterAccessory(accessory)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Homebridge calls this for every accessory restored from cache. */
|
|
132
|
+
configureAccessory(accessory) {
|
|
133
|
+
this.cached.set(accessory.UUID, accessory)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
getFakeGato() {
|
|
137
|
+
if (this._fakegato !== undefined) {
|
|
138
|
+
return this._fakegato
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
this._fakegato = eveService(this.api)
|
|
142
|
+
} catch (err) {
|
|
143
|
+
this.log.warn(`Eve history unavailable: ${err.message}`)
|
|
144
|
+
this._fakegato = null
|
|
145
|
+
}
|
|
146
|
+
return this._fakegato
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
getOrCreate(uuid, name, toRegister) {
|
|
150
|
+
let acc = this.cached.get(uuid)
|
|
151
|
+
if (!acc) {
|
|
152
|
+
acc = new this.api.platformAccessory(name, uuid)
|
|
153
|
+
toRegister.push(acc)
|
|
154
|
+
this.log.info(`Adding accessory: ${name}`)
|
|
155
|
+
}
|
|
156
|
+
acc.context.hpPrinter = true
|
|
157
|
+
return acc
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
didFinishLaunching() {
|
|
161
|
+
const wanted = new Set()
|
|
162
|
+
const toRegister = []
|
|
163
|
+
|
|
164
|
+
// "Matter only": publish the printer over Matter alone, no HomeKit accessories.
|
|
165
|
+
// Only honoured when Matter is actually available, so nothing disappears by accident.
|
|
166
|
+
const matterReason = this.energy.unavailableReason()
|
|
167
|
+
const matterOnly = this.config.matterOnly === true && !matterReason
|
|
168
|
+
if (this.config.matterOnly === true && matterReason) {
|
|
169
|
+
this.log.warn(
|
|
170
|
+
`"Matter only" is turned on, but ${matterReason}. Keeping the HomeKit accessories until Matter is available.`,
|
|
171
|
+
)
|
|
172
|
+
} else if (matterOnly) {
|
|
173
|
+
this.log.info(
|
|
174
|
+
'"Matter only" is on: the printer is published over Matter only (no HomeKit accessories). Ink levels are shown on the dashboard.',
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const device of this.devices) {
|
|
179
|
+
const poller = new PrinterPoller(device, this.log)
|
|
180
|
+
this.pollers.set(device.id, poller)
|
|
181
|
+
|
|
182
|
+
if (matterOnly) {
|
|
183
|
+
// No HomeKit accessory to keep the last-known ink levels in, so keep them in a file
|
|
184
|
+
this.seedFromFile(device, poller)
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// "<name>" accessory: printing sensor, fault/online status and ink tiles
|
|
189
|
+
const statusUuid = device.id
|
|
190
|
+
wanted.add(statusUuid)
|
|
191
|
+
const acc = this.getOrCreate(statusUuid, device.name, toRegister)
|
|
192
|
+
// Seed with the last known state so ink levels survive restarts while the printer is off
|
|
193
|
+
const last = acc.context.lastState
|
|
194
|
+
if (last && Array.isArray(last.markers)) {
|
|
195
|
+
Object.assign(poller.state, {
|
|
196
|
+
markers: last.markers,
|
|
197
|
+
model: device.model || last.model || null,
|
|
198
|
+
lastSeen: last.lastSeen || null,
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
poller.on('update', (s) => {
|
|
202
|
+
acc.context.lastState = { markers: s.markers, model: s.model, lastSeen: s.lastSeen }
|
|
203
|
+
})
|
|
204
|
+
this.handlers.push(new PrinterAccessory(this, acc, device, poller))
|
|
205
|
+
|
|
206
|
+
// Switch 1: "<name> Online" - on while the printer is switched on and reachable.
|
|
207
|
+
// With Matter, this is one combined "<name> Online" outlet that also carries the
|
|
208
|
+
// energy reading (published by MatterEnergyPublisher), so no separate HomeKit switch.
|
|
209
|
+
device.onlineViaMatter = device.onlineSwitch && device.energy.enabled && !this.energy.unavailableReason()
|
|
210
|
+
if (device.onlineSwitch && !device.onlineViaMatter) {
|
|
211
|
+
const uuid = this.api.hap.uuid.generate(`hp-printer:${device.name}:online`)
|
|
212
|
+
wanted.add(uuid)
|
|
213
|
+
const sw = this.getOrCreate(uuid, device.onlineName, toRegister)
|
|
214
|
+
this.handlers.push(new OnlineSwitchAccessory(this, sw, device, poller))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Switch 2: "<name> Printing" - on only while a job is printing
|
|
218
|
+
if (device.printingSwitch) {
|
|
219
|
+
const uuid = this.api.hap.uuid.generate(`hp-printer:${device.name}:printing`)
|
|
220
|
+
wanted.add(uuid)
|
|
221
|
+
const sw = this.getOrCreate(uuid, device.printingName, toRegister)
|
|
222
|
+
this.handlers.push(new PrintingSwitchAccessory(this, sw, device, poller))
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (toRegister.length) {
|
|
227
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, toRegister)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const stale = [
|
|
231
|
+
...this.cached.values(),
|
|
232
|
+
].filter(a => !wanted.has(a.UUID))
|
|
233
|
+
if (stale.length) {
|
|
234
|
+
stale.forEach(a => this.log.info(`Removing accessory: ${a.displayName}`))
|
|
235
|
+
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, stale)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
for (const poller of this.pollers.values()) {
|
|
239
|
+
poller.start()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
this.energy.start(this.devices, this.pollers, { matterOnly }).catch(err => this.log.warn(`Matter: ${err.message}`))
|
|
243
|
+
|
|
244
|
+
const dash = this.config.dashboard || {}
|
|
245
|
+
if (dash.enabled !== false && this.devices.length) {
|
|
246
|
+
this.dashboard = new DashboardServer({
|
|
247
|
+
port: Number(dash.port) || 8587,
|
|
248
|
+
host: dash.host || '0.0.0.0',
|
|
249
|
+
log: this.log,
|
|
250
|
+
getStatus: () => this.getStatus(),
|
|
251
|
+
onRefresh: () => Promise.all([
|
|
252
|
+
...this.pollers.values(),
|
|
253
|
+
].map(p => p.poll())),
|
|
254
|
+
})
|
|
255
|
+
for (const poller of this.pollers.values()) {
|
|
256
|
+
poller.on('update', () => this.dashboard.broadcast())
|
|
257
|
+
}
|
|
258
|
+
this.dashboard.start()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!this.devices.length) {
|
|
262
|
+
this.log.warn(
|
|
263
|
+
'No printers configured yet. Open Plugins → Printer → Settings and enter your printer\'s IP address or .local name.',
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Last-known ink levels for Matter-only mode, kept in <storage>/hp-printer/state-<id>.json */
|
|
269
|
+
seedFromFile(device, poller) {
|
|
270
|
+
const file = path.join(this.api.user.storagePath(), 'hp-printer', `state-${device.id}.json`)
|
|
271
|
+
try {
|
|
272
|
+
const last = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
273
|
+
if (last && Array.isArray(last.markers)) {
|
|
274
|
+
Object.assign(poller.state, {
|
|
275
|
+
markers: last.markers,
|
|
276
|
+
model: device.model || last.model || null,
|
|
277
|
+
lastSeen: last.lastSeen || null,
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
} catch (_) {
|
|
281
|
+
/* first run */
|
|
282
|
+
}
|
|
283
|
+
let timer = null
|
|
284
|
+
poller.on('update', (st) => {
|
|
285
|
+
if (timer) {
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
timer = setTimeout(() => {
|
|
289
|
+
timer = null
|
|
290
|
+
try {
|
|
291
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
292
|
+
// through a temporary file, so a power cut can't leave it half written
|
|
293
|
+
fs.writeFileSync(`${file}.tmp`, JSON.stringify({ markers: st.markers, model: st.model, lastSeen: st.lastSeen }))
|
|
294
|
+
fs.renameSync(`${file}.tmp`, file)
|
|
295
|
+
} catch (err) {
|
|
296
|
+
this.log.debug(`${device.name}: could not save state (${err.message})`)
|
|
297
|
+
}
|
|
298
|
+
}, 2000)
|
|
299
|
+
if (timer.unref) {
|
|
300
|
+
timer.unref()
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
getStatus() {
|
|
306
|
+
return {
|
|
307
|
+
plugin: PLUGIN_NAME,
|
|
308
|
+
version: this.version,
|
|
309
|
+
generated: new Date().toISOString(),
|
|
310
|
+
printers: [
|
|
311
|
+
...this.pollers.values(),
|
|
312
|
+
].map((p) => {
|
|
313
|
+
const energy = this.energy.status()[p.state.id]
|
|
314
|
+
return energy
|
|
315
|
+
? { ...p.state, energy }
|
|
316
|
+
: p.state
|
|
317
|
+
}),
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
shutdown() {
|
|
322
|
+
this.energy.shutdown()
|
|
323
|
+
for (const p of this.pollers.values()) {
|
|
324
|
+
p.stop()
|
|
325
|
+
}
|
|
326
|
+
for (const h of this.handlers) {
|
|
327
|
+
h.destroy()
|
|
328
|
+
}
|
|
329
|
+
if (this.dashboard) {
|
|
330
|
+
this.dashboard.stop()
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export { PLATFORM_NAME, PLUGIN_NAME, PrinterPlatform }
|