@homebridge-plugins/homebridge-printer 0.0.0-stage → 1.0.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/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 }
@@ -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 }