@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.
@@ -0,0 +1,333 @@
1
+ const NOW_EPOCH_OFFSET = 978307200 // seconds between 1970-01-01 and 2001-01-01 (Eve epoch)
2
+
3
+ class PrinterAccessory {
4
+ /**
5
+ * @param {import('./platform').PrinterPlatform} platform
6
+ * @param {*} accessory PlatformAccessory
7
+ * @param {*} device normalised printer config
8
+ * @param {import('./poller').PrinterPoller} poller
9
+ */
10
+ constructor(platform, accessory, device, poller) {
11
+ this.platform = platform
12
+ this.api = platform.api
13
+ this.log = platform.log
14
+ this.hap = platform.api.hap
15
+ this.C = platform.customChars
16
+ this.accessory = accessory
17
+ this.device = device
18
+ this.poller = poller
19
+ this.history = null
20
+
21
+ const { Service, Characteristic } = this.hap
22
+
23
+ accessory.on('identify', () => this.log.info(`${accessory.displayName}: identify requested`))
24
+
25
+ accessory
26
+ .getService(Service.AccessoryInformation)
27
+ .setCharacteristic(Characteristic.Manufacturer, device.manufacturer || 'HP')
28
+ .setCharacteristic(Characteristic.Model, device.model || 'Printer')
29
+ .setCharacteristic(Characteristic.SerialNumber, device.serialNumber || device.id.slice(0, 12))
30
+ .setCharacteristic(Characteristic.FirmwareRevision, platform.version)
31
+
32
+ // ----- "Printing" contact sensor -------------------------------------------------------
33
+ this.contact
34
+ = accessory.getServiceById(Service.ContactSensor, 'contact')
35
+ || accessory.getService(Service.ContactSensor)
36
+ || accessory.addService(Service.ContactSensor, device.name, 'contact')
37
+ this.ensureName(this.contact, device.name)
38
+
39
+ for (const c of [Characteristic.StatusActive, Characteristic.StatusFault]) {
40
+ if (!this.contact.testCharacteristic(c)) {
41
+ this.contact.addCharacteristic(c)
42
+ }
43
+ }
44
+
45
+ // Online state as a characteristic (visible in Eve / Controller apps)
46
+ if (device.onlineCharacteristic) {
47
+ if (!this.contact.testCharacteristic(this.C.PrinterOnline)) {
48
+ this.contact.addCharacteristic(this.C.PrinterOnline)
49
+ }
50
+ } else if (this.contact.testCharacteristic(this.C.PrinterOnline)) {
51
+ this.contact.removeCharacteristic(this.contact.getCharacteristic(this.C.PrinterOnline))
52
+ }
53
+
54
+ this.setupHistory()
55
+
56
+ // ----- Ink / toner levels ----------------------------------------------------------------
57
+ if (!device.marker) {
58
+ this.removeMarkerServices([])
59
+ }
60
+
61
+ poller.on('update', state => this.update(state))
62
+ }
63
+
64
+ ensureName(service, name) {
65
+ const { Characteristic } = this.hap
66
+ service.setCharacteristic(Characteristic.Name, name)
67
+ // iOS 16+ uses ConfiguredName for service labels inside grouped accessories
68
+ if (Characteristic.ConfiguredName) {
69
+ if (!service.testCharacteristic(Characteristic.ConfiguredName)) {
70
+ service.addCharacteristic(Characteristic.ConfiguredName)
71
+ }
72
+ service.updateCharacteristic(Characteristic.ConfiguredName, name)
73
+ }
74
+ }
75
+
76
+ setupHistory() {
77
+ const { Characteristic } = this.hap
78
+ const C = this.C
79
+ const eveChars = [
80
+ C.EveLastActivation,
81
+ C.EveTimesOpened,
82
+ C.EveOpenDuration,
83
+ C.EveClosedDuration,
84
+ C.EveResetTotal,
85
+ ]
86
+
87
+ const FakeGato = this.platform.getFakeGato()
88
+ if (!this.device.history || !FakeGato) {
89
+ for (const c of eveChars) {
90
+ if (this.contact.testCharacteristic(c)) {
91
+ this.contact.removeCharacteristic(this.contact.getCharacteristic(c))
92
+ }
93
+ }
94
+ return
95
+ }
96
+
97
+ for (const c of eveChars) {
98
+ if (!this.contact.testCharacteristic(c)) {
99
+ this.contact.addCharacteristic(c)
100
+ }
101
+ }
102
+ const ctx = this.accessory.context
103
+ ctx.timesPrinted = ctx.timesPrinted || 0
104
+ this.contact.updateCharacteristic(C.EveTimesOpened, ctx.timesPrinted)
105
+
106
+ this.contact.getCharacteristic(C.EveResetTotal).onSet(() => {
107
+ ctx.timesPrinted = 0
108
+ this.contact.updateCharacteristic(C.EveTimesOpened, 0)
109
+ this.contact.updateCharacteristic(C.EveResetTotal, Math.round(Date.now() / 1000) - NOW_EPOCH_OFFSET)
110
+ this.log.info(`${this.accessory.displayName}: Eve history counters reset`)
111
+ })
112
+
113
+ try {
114
+ // the org's copy of fakegato, which also re-sends the last state every 10 minutes
115
+ this.history = new FakeGato('door', this.accessory, {
116
+ log: () => {},
117
+ })
118
+ } catch (err) {
119
+ this.log.warn(`${this.accessory.displayName}: Eve history disabled (${err.message})`)
120
+ this.history = null
121
+ return
122
+ }
123
+
124
+ this.contact.getCharacteristic(Characteristic.ContactSensorState).on('change', ({ oldValue, newValue }) => {
125
+ if (oldValue === newValue || !this.history) {
126
+ return
127
+ }
128
+ const now = Math.round(Date.now() / 1000)
129
+ const initial = Number(this.history.getInitialTime())
130
+ const since = Number.isFinite(initial) && initial > 0
131
+ ? Math.max(0, now - initial)
132
+ : null
133
+ if (newValue) {
134
+ ctx.timesPrinted += 1
135
+ this.contact.updateCharacteristic(C.EveTimesOpened, ctx.timesPrinted)
136
+ if (since !== null) {
137
+ this.contact.updateCharacteristic(C.EveLastActivation, since)
138
+ }
139
+ }
140
+ this.history.addEntry({
141
+ time: now,
142
+ status: newValue ? 1 : 0,
143
+ })
144
+ })
145
+ }
146
+
147
+ removeMarkerServices(keepSubtypes) {
148
+ const { Service } = this.hap
149
+ const stale = this.accessory.services.filter(
150
+ s => s.UUID === Service.FilterMaintenance.UUID && !keepSubtypes.includes(s.subtype),
151
+ )
152
+ for (const s of stale) {
153
+ this.log.info(`${this.accessory.displayName}: removing ink service "${s.displayName}"`)
154
+ this.accessory.removeService(s)
155
+ }
156
+ return stale.length > 0
157
+ }
158
+
159
+ syncMarkerServices(markers) {
160
+ const { Service, Characteristic } = this.hap
161
+ let changed = false
162
+ const wanted = markers.map(m => `marker-${m.index}`)
163
+ changed = this.removeMarkerServices(wanted) || changed
164
+
165
+ for (const m of markers) {
166
+ const subtype = `marker-${m.index}`
167
+ let svc = this.accessory.getServiceById(Service.FilterMaintenance, subtype)
168
+ if (!svc) {
169
+ this.log.info(`${this.accessory.displayName}: adding ink service "${m.name}"`)
170
+ svc = this.accessory.addService(Service.FilterMaintenance, m.name, subtype)
171
+ changed = true
172
+ }
173
+ if (!svc.testCharacteristic(Characteristic.FilterLifeLevel)) {
174
+ svc.addCharacteristic(Characteristic.FilterLifeLevel)
175
+ }
176
+ if (svc.displayName !== m.name || !svc._hpNamed) {
177
+ this.ensureName(svc, m.name)
178
+ svc._hpNamed = true
179
+ }
180
+
181
+ const level = m.level !== null
182
+ ? m.level
183
+ : m.someRemaining ? 50 : 0
184
+ const threshold
185
+ = m.low !== null && m.low > 0
186
+ ? Math.max(m.low, this.device.lowInkThreshold)
187
+ : this.device.lowInkThreshold
188
+ const low = m.level !== null && m.level <= threshold
189
+ svc.updateCharacteristic(Characteristic.FilterLifeLevel, level)
190
+ svc.updateCharacteristic(
191
+ Characteristic.FilterChangeIndication,
192
+ low ? Characteristic.FilterChangeIndication.CHANGE_FILTER : Characteristic.FilterChangeIndication.FILTER_OK,
193
+ )
194
+ }
195
+ if (changed) {
196
+ this.api.updatePlatformAccessories([this.accessory])
197
+ }
198
+ }
199
+
200
+ update(state) {
201
+ const { Characteristic } = this.hap
202
+ const printing = state.printing
203
+ const fault = !state.online || state.status === 'stopped' || state.reasons.some(r => r.endsWith('-error'))
204
+
205
+ this.contact.updateCharacteristic(
206
+ Characteristic.ContactSensorState,
207
+ printing
208
+ ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED
209
+ : Characteristic.ContactSensorState.CONTACT_DETECTED,
210
+ )
211
+ this.contact.updateCharacteristic(Characteristic.StatusActive, state.online)
212
+ this.contact.updateCharacteristic(
213
+ Characteristic.StatusFault,
214
+ fault ? Characteristic.StatusFault.GENERAL_FAULT : Characteristic.StatusFault.NO_FAULT,
215
+ )
216
+ if (this.device.onlineCharacteristic) {
217
+ this.contact.updateCharacteristic(this.C.PrinterOnline, state.online)
218
+ }
219
+
220
+ if (this.device.marker && state.markers.length) {
221
+ this.syncMarkerServices(state.markers)
222
+ }
223
+ }
224
+
225
+ destroy() {
226
+ clearInterval(this.historyTimer)
227
+ }
228
+ }
229
+
230
+ /**
231
+ * "Printer Printing" switch: ON only while the printer is printing a job, OFF otherwise
232
+ * (idle, asleep or switched off). It reports state only - tapping it in the
233
+ * Home app snaps back to the real value, because a printer can't be started
234
+ * or stopped over the network.
235
+ */
236
+ class PrintingSwitchAccessory {
237
+ constructor(platform, accessory, device, poller) {
238
+ const { Service, Characteristic } = platform.api.hap
239
+ this.accessory = accessory
240
+
241
+ accessory
242
+ .getService(Service.AccessoryInformation)
243
+ .setCharacteristic(Characteristic.Manufacturer, device.manufacturer || 'HP')
244
+ .setCharacteristic(Characteristic.Model, device.model || 'Printer')
245
+ .setCharacteristic(Characteristic.SerialNumber, `${device.serialNumber || device.id.slice(0, 12)}-P`)
246
+ .setCharacteristic(Characteristic.FirmwareRevision, platform.version)
247
+
248
+ const name = device.printingName
249
+ this.service
250
+ = accessory.getServiceById(Service.Switch, 'printing') || accessory.addService(Service.Switch, name, 'printing')
251
+ this.service.setCharacteristic(Characteristic.Name, name)
252
+ if (Characteristic.ConfiguredName) {
253
+ if (!this.service.testCharacteristic(Characteristic.ConfiguredName)) {
254
+ this.service.addCharacteristic(Characteristic.ConfiguredName)
255
+ }
256
+ this.service.updateCharacteristic(Characteristic.ConfiguredName, name)
257
+ }
258
+
259
+ const isPrinting = state => !!state.printing
260
+ const on = this.service.getCharacteristic(Characteristic.On)
261
+ on.onGet(() => isPrinting(poller.state))
262
+ on.onSet(() => {
263
+ setTimeout(() => on.updateValue(isPrinting(poller.state)), 300)
264
+ })
265
+
266
+ let last = null
267
+ poller.on('update', (state) => {
268
+ const now = isPrinting(state)
269
+ if (now !== last) {
270
+ if (last !== null) {
271
+ platform.log.info(`${name}: ${now ? 'printing started' : 'printing finished'}`)
272
+ }
273
+ last = now
274
+ }
275
+ on.updateValue(now)
276
+ })
277
+ }
278
+
279
+ destroy() {}
280
+ }
281
+
282
+ /**
283
+ * "Printer Online" switch: ON while the printer is switched on and answering on the
284
+ * network, OFF when it is off, asleep or disconnected. Reports state only.
285
+ */
286
+ class OnlineSwitchAccessory {
287
+ constructor(platform, accessory, device, poller) {
288
+ const { Service, Characteristic } = platform.api.hap
289
+ this.accessory = accessory
290
+
291
+ accessory
292
+ .getService(Service.AccessoryInformation)
293
+ .setCharacteristic(Characteristic.Manufacturer, device.manufacturer || 'HP')
294
+ .setCharacteristic(Characteristic.Model, device.model || 'Printer')
295
+ .setCharacteristic(Characteristic.SerialNumber, `${device.serialNumber || device.id.slice(0, 12)}-S`)
296
+ .setCharacteristic(Characteristic.FirmwareRevision, platform.version)
297
+
298
+ const name = device.onlineName
299
+ this.service
300
+ = accessory.getServiceById(Service.Switch, 'online')
301
+ || accessory.getService(Service.Switch)
302
+ || accessory.addService(Service.Switch, name, 'online')
303
+ this.service.setCharacteristic(Characteristic.Name, name)
304
+ if (Characteristic.ConfiguredName) {
305
+ if (!this.service.testCharacteristic(Characteristic.ConfiguredName)) {
306
+ this.service.addCharacteristic(Characteristic.ConfiguredName)
307
+ }
308
+ this.service.updateCharacteristic(Characteristic.ConfiguredName, name)
309
+ }
310
+
311
+ const on = this.service.getCharacteristic(Characteristic.On)
312
+ on.onGet(() => !!poller.state.online)
313
+ on.onSet(() => {
314
+ setTimeout(() => on.updateValue(!!poller.state.online), 300)
315
+ })
316
+
317
+ let last = null
318
+ poller.on('update', (state) => {
319
+ const now = !!state.online
320
+ if (now !== last) {
321
+ if (last !== null) {
322
+ platform.log.info(`${name}: printer is ${now ? 'on' : 'off / unreachable'}`)
323
+ }
324
+ last = now
325
+ }
326
+ on.updateValue(now)
327
+ })
328
+ }
329
+
330
+ destroy() {}
331
+ }
332
+
333
+ export { OnlineSwitchAccessory, PrinterAccessory, PrintingSwitchAccessory }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Custom / Eve characteristics written as ES classes.
3
+ *
4
+ * The original plugin used `Characteristic.call(this, ...)` + util.inherits and
5
+ * `Characteristic.Formats/Perms/Units`. HAP-NodeJS 1.x (Homebridge 2) turned
6
+ * Characteristic/Service into real classes (so `.call` throws
7
+ * "Class constructor ... cannot be invoked without 'new'") and removed the
8
+ * static Formats/Perms/Units aliases. Everything here uses `class extends`
9
+ * and `hap.Formats/Perms/Units`, which work on Homebridge 1.6+ and 2.x.
10
+ */
11
+
12
+ const DEFS = [
13
+ // name, displayName, UUID, format, unit, perms
14
+ [
15
+ 'PrinterOnline',
16
+ 'Printer Online',
17
+ '65ede48d-5d4e-4408-8a6f-feaa57b7320e',
18
+ 'BOOL',
19
+ null,
20
+ ['PAIRED_READ', 'NOTIFY'],
21
+ ],
22
+ [
23
+ 'EveResetTotal',
24
+ 'Reset Total',
25
+ 'E863F112-079E-48FF-8F27-9C2605A29F52',
26
+ 'UINT32',
27
+ 'SECONDS',
28
+ ['PAIRED_READ', 'NOTIFY', 'PAIRED_WRITE'],
29
+ ],
30
+ [
31
+ 'EveLastActivation',
32
+ 'Last Activation',
33
+ 'E863F11A-079E-48FF-8F27-9C2605A29F52',
34
+ 'UINT32',
35
+ 'SECONDS',
36
+ ['PAIRED_READ', 'NOTIFY'],
37
+ ],
38
+ [
39
+ 'EveTimesOpened',
40
+ 'Times Opened',
41
+ 'E863F129-079E-48FF-8F27-9C2605A29F52',
42
+ 'UINT32',
43
+ null,
44
+ ['PAIRED_READ', 'NOTIFY'],
45
+ ],
46
+ [
47
+ 'EveOpenDuration',
48
+ 'Open Duration',
49
+ 'E863F118-079E-48FF-8F27-9C2605A29F52',
50
+ 'UINT32',
51
+ 'SECONDS',
52
+ ['PAIRED_READ', 'NOTIFY', 'PAIRED_WRITE'],
53
+ ],
54
+ [
55
+ 'EveClosedDuration',
56
+ 'Closed Duration',
57
+ 'E863F119-079E-48FF-8F27-9C2605A29F52',
58
+ 'UINT32',
59
+ 'SECONDS',
60
+ ['PAIRED_READ', 'NOTIFY', 'PAIRED_WRITE'],
61
+ ],
62
+ ]
63
+
64
+ let cache = null
65
+
66
+ function createCharacteristics(hap) {
67
+ if (cache) {
68
+ return cache
69
+ }
70
+ const { Characteristic, Formats, Perms, Units } = hap
71
+ cache = {}
72
+
73
+ for (const [key, displayName, uuid, format, unit, perms] of DEFS) {
74
+ const props = {
75
+ format: Formats[format],
76
+ perms: perms.map(p => Perms[p]),
77
+ }
78
+ if (unit) {
79
+ props.unit = Units[unit]
80
+ }
81
+
82
+ const Klass = class extends Characteristic {
83
+ constructor() {
84
+ super(displayName, uuid, props)
85
+ this.value = this.getDefaultValue()
86
+ }
87
+ }
88
+ Object.defineProperty(Klass, 'name', { value: key })
89
+ Klass.UUID = uuid
90
+ cache[key] = Klass
91
+ }
92
+ return cache
93
+ }
94
+
95
+ export { createCharacteristics }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Small built-in web dashboard: http://<homebridge-host>:8587/
3
+ * Serves the files in homebridge-ui/public/, plus a JSON API and a live Server-Sent Events stream.
4
+ */
5
+
6
+ import fs from 'node:fs'
7
+ import http from 'node:http'
8
+ import path from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
10
+
11
+ const PUBLIC_DIR = fileURLToPath(new URL('./homebridge-ui/public/', import.meta.url))
12
+ const FILES = {
13
+ '/': ['dashboard.html', 'text/html; charset=utf-8'],
14
+ '/dashboard.js': ['dashboard.js', 'application/javascript; charset=utf-8'],
15
+ '/dashboard.css': ['dashboard.css', 'text/css; charset=utf-8'],
16
+ }
17
+
18
+ class DashboardServer {
19
+ constructor({ port, host, log, getStatus, onRefresh }) {
20
+ this.port = port
21
+ this.host = host
22
+ this.log = log
23
+ this.getStatus = getStatus
24
+ this.onRefresh = onRefresh
25
+ this.clients = new Set()
26
+ this.server = null
27
+ }
28
+
29
+ start() {
30
+ this.server = http.createServer((req, res) => this.handle(req, res))
31
+ this.server.on('error', (err) => {
32
+ if (err.code === 'EADDRINUSE') {
33
+ this.log.warn(
34
+ `Dashboard: port ${this.port} is already in use. Set a different "dashboard.port" in the plugin config.`,
35
+ )
36
+ } else {
37
+ this.log.warn(`Dashboard error: ${err.message}`)
38
+ }
39
+ })
40
+ this.server.listen(this.port, this.host, () => {
41
+ this.log.info(`Printer dashboard running at http://<your-homebridge-host>:${this.port}/`)
42
+ })
43
+ this.heartbeat = setInterval(() => {
44
+ for (const res of this.clients) {
45
+ res.write(': ping\n\n')
46
+ }
47
+ }, 25000)
48
+ if (this.heartbeat.unref) {
49
+ this.heartbeat.unref()
50
+ }
51
+ }
52
+
53
+ stop() {
54
+ clearInterval(this.heartbeat)
55
+ for (const res of this.clients) {
56
+ res.end()
57
+ }
58
+ this.clients.clear()
59
+ if (this.server) {
60
+ this.server.close()
61
+ }
62
+ }
63
+
64
+ broadcast() {
65
+ if (!this.clients.size) {
66
+ return
67
+ }
68
+ if (this._pending) {
69
+ return
70
+ }
71
+ this._pending = setTimeout(() => {
72
+ this._pending = null
73
+ const data = `data: ${JSON.stringify(this.getStatus())}\n\n`
74
+ for (const res of this.clients) {
75
+ res.write(data)
76
+ }
77
+ }, 200)
78
+ }
79
+
80
+ send(res, code, body, type = 'application/json; charset=utf-8') {
81
+ res.writeHead(code, {
82
+ 'Content-Type': type,
83
+ 'Cache-Control': 'no-store',
84
+ 'X-Content-Type-Options': 'nosniff',
85
+ })
86
+ res.end(body)
87
+ }
88
+
89
+ handle(req, res) {
90
+ let route
91
+ try {
92
+ route = new URL(req.url, 'http://localhost').pathname
93
+ } catch {
94
+ // any device on the network can send a path the URL parser can't read
95
+ return this.send(res, 400, 'Bad request', 'text/plain')
96
+ }
97
+
98
+ if (req.method === 'GET' && route === '/api/status') {
99
+ return this.send(res, 200, JSON.stringify(this.getStatus()))
100
+ }
101
+
102
+ if (req.method === 'POST' && route === '/api/refresh') {
103
+ return Promise.resolve(this.onRefresh())
104
+ .catch(() => {})
105
+ .then(() => this.send(res, 200, JSON.stringify(this.getStatus())))
106
+ }
107
+
108
+ if (req.method === 'GET' && route === '/api/events') {
109
+ res.writeHead(200, {
110
+ 'Content-Type': 'text/event-stream',
111
+ 'Cache-Control': 'no-store',
112
+ 'Connection': 'keep-alive',
113
+ })
114
+ res.write(`data: ${JSON.stringify(this.getStatus())}\n\n`)
115
+ this.clients.add(res)
116
+ req.on('close', () => this.clients.delete(res))
117
+ return
118
+ }
119
+
120
+ if (req.method === 'GET' && FILES[route]) {
121
+ const [file, type] = FILES[route]
122
+ return fs.readFile(path.join(PUBLIC_DIR, file), (err, buf) => {
123
+ if (err) {
124
+ return this.send(res, 500, 'Dashboard file missing', 'text/plain')
125
+ }
126
+ this.send(res, 200, buf, type)
127
+ })
128
+ }
129
+
130
+ this.send(res, 404, 'Not found', 'text/plain')
131
+ }
132
+ }
133
+
134
+ export { DashboardServer }