@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
|
@@ -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 }
|