@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.
@@ -0,0 +1,421 @@
1
+ /* Printer dashboard – served by the plugin at http://<homebridge>:8587/ */
2
+ ;(function (global) {
3
+ 'use strict'
4
+
5
+ const REASONS = {
6
+ 'media-empty': 'Out of paper',
7
+ 'media-needed': 'Load paper',
8
+ 'media-jam': 'Paper jam',
9
+ 'door-open': 'A door is open',
10
+ 'cover-open': 'Cover is open',
11
+ 'input-tray-missing': 'Paper tray missing',
12
+ 'output-tray-missing': 'Output tray missing',
13
+ 'marker-supply-empty': 'Cartridge empty',
14
+ 'marker-supply-low': 'Ink low',
15
+ 'toner-empty': 'Toner empty',
16
+ 'toner-low': 'Toner low',
17
+ 'paused': 'Paused',
18
+ 'offline': 'Printer reports offline',
19
+ 'other': 'Needs attention',
20
+ }
21
+
22
+ function el(tag, cls, text) {
23
+ const n = document.createElement(tag)
24
+ if (cls) {
25
+ n.className = cls
26
+ }
27
+ if (text !== undefined && text !== null) {
28
+ n.textContent = text
29
+ }
30
+ return n
31
+ }
32
+
33
+ function reasonText(r) {
34
+ const base = String(r).replace(/-(?:error|warning|report)$/, '')
35
+ return (
36
+ REASONS[base]
37
+ || base.replace(/-/g, ' ').replace(/^\w/, (c) => {
38
+ return c.toUpperCase()
39
+ })
40
+ )
41
+ }
42
+
43
+ function fmtTime(iso) {
44
+ if (!iso) {
45
+ return '—'
46
+ }
47
+ const d = new Date(iso)
48
+ return `${d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} ${d.toLocaleDateString([], {
49
+ month: 'short',
50
+ day: 'numeric',
51
+ year: 'numeric',
52
+ })}`
53
+ }
54
+
55
+ // Normalise HP's pure RGB marker colours into friendlier ink tones (like HP Smart does)
56
+ const INK = {
57
+ '#00FFFF': '#2d9fd8',
58
+ '#FF00FF': '#d6337e',
59
+ '#FFFF00': '#f7cf3b',
60
+ '#000000': '#1d1d22',
61
+ }
62
+ function inkColour(c) {
63
+ return INK[c] || c
64
+ }
65
+
66
+ function isLow(m, threshold) {
67
+ if (m.level === null || m.level === undefined) {
68
+ return false
69
+ }
70
+ const t = m.low !== null && m.low > 0
71
+ ? Math.max(m.low, threshold)
72
+ : threshold
73
+ return m.level <= t
74
+ }
75
+
76
+ function renderInk(markers, threshold) {
77
+ const wrap = el('div', 'hp-inks')
78
+ markers.forEach((m) => {
79
+ const colours = (m.colors && m.colors.length
80
+ ? m.colors
81
+ : ['#8a8a96']).map(inkColour)
82
+ const low = isLow(m, threshold)
83
+ const unknown = m.level === null || m.level === undefined
84
+ const col = el('div', 'hp-ink')
85
+ col.title = `${m.name}: ${unknown
86
+ ? (m.someRemaining ? 'some ink remaining' : 'level unknown')
87
+ : `${m.level}%`}`
88
+
89
+ const tube = el('div', `hp-tube${low ? ' is-low' : ''}${unknown ? ' is-unknown' : ''}`)
90
+ tube.setAttribute('role', 'meter')
91
+ tube.setAttribute('aria-label', m.name)
92
+ tube.setAttribute('aria-valuemin', '0')
93
+ tube.setAttribute('aria-valuemax', '100')
94
+ if (!unknown) {
95
+ tube.setAttribute('aria-valuenow', String(m.level))
96
+ }
97
+
98
+ const level = unknown
99
+ ? (m.someRemaining ? 50 : 0)
100
+ : m.level
101
+ colours.forEach((c) => {
102
+ const stripe = el('div', 'hp-stripe')
103
+ const fill = el('div', 'hp-fill')
104
+ fill.style.setProperty('--ink', c)
105
+ fill.style.height = `${level}%`
106
+ stripe.appendChild(fill)
107
+ tube.appendChild(stripe)
108
+ })
109
+ col.appendChild(tube)
110
+
111
+ const dots = el('div', `hp-dots hp-dots-${Math.min(colours.length, 3)}`)
112
+ colours.slice(0, 3).forEach((c) => {
113
+ const d = el('span', 'hp-dot')
114
+ d.style.background = c
115
+ dots.appendChild(d)
116
+ })
117
+ col.appendChild(dots)
118
+ col.appendChild(el('div', `hp-pct${low ? ' is-low' : ''}`, unknown
119
+ ? '?'
120
+ : `${m.level}%`))
121
+ wrap.appendChild(col)
122
+ })
123
+ return wrap
124
+ }
125
+
126
+ const PRINTER_SVG
127
+ = '<svg viewBox="0 0 520 300" class="hp-printer-svg" aria-hidden="true">'
128
+ + '<defs>'
129
+ + '<linearGradient id="hpBody" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="var(--body-hi)"/><stop offset="1" stop-color="var(--body-lo)"/></linearGradient>'
130
+ + '<linearGradient id="hpLid" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="var(--body-hi)"/><stop offset="1" stop-color="var(--body-mid)"/></linearGradient>'
131
+ + '<clipPath id="hpSlot"><rect x="150" y="196" width="230" height="120"/></clipPath>'
132
+ + '</defs>'
133
+ + '<ellipse cx="262" cy="282" rx="230" ry="12" fill="var(--shadow)"/>'
134
+ // paper sticking out of the top feeder
135
+ + '<rect x="170" y="38" width="190" height="22" rx="2" fill="var(--paper)" stroke="var(--line)"/>'
136
+ // main body
137
+ + '<path d="M42 92 Q42 74 60 72 L470 72 Q488 74 488 92 L494 250 Q494 268 476 268 L54 268 Q36 268 36 250 Z" fill="url(#hpBody)" stroke="var(--line)"/>'
138
+ // accent band on the left edge (blue-grey like the DeskJet in the reference)
139
+ + '<path d="M42 92 Q42 74 60 72 L120 72 L110 128 L40 128 Z" fill="var(--accent-band)"/>'
140
+ + '<path d="M470 72 Q488 74 488 92 L490 128 L430 128 L420 72 Z" fill="var(--accent-band)"/>'
141
+ // scanner lid
142
+ + '<path d="M100 56 Q100 46 112 46 L420 46 Q432 46 432 56 L436 122 Q436 130 426 130 L106 130 Q96 130 96 122 Z" fill="url(#hpLid)" stroke="var(--line)"/>'
143
+ + '<ellipse cx="300" cy="74" rx="22" ry="5" fill="var(--logo)"/>'
144
+ // control panel
145
+ + '<rect x="56" y="96" width="34" height="58" rx="5" fill="var(--panel)" transform="skewX(-6)"/>'
146
+ + '<circle cx="64" cy="108" r="3" class="hp-led"/>'
147
+ + '<rect x="62" y="118" width="14" height="10" rx="1.5" fill="var(--panel-screen)"/>'
148
+ // label plate
149
+ + '<rect x="58" y="176" width="66" height="14" rx="3" fill="var(--plate)" stroke="var(--line)"/>'
150
+ // output opening
151
+ + '<path d="M152 150 L378 150 Q390 150 390 162 L390 238 L140 238 L140 162 Q140 150 152 150 Z" fill="var(--opening)" stroke="var(--line)"/>'
152
+ + '<rect x="152" y="190" width="226" height="14" rx="3" fill="var(--slot)"/>'
153
+ + '<rect x="248" y="146" width="34" height="8" rx="2" fill="var(--body-mid)" stroke="var(--line)"/>'
154
+ + '<g fill="var(--rollers)"><rect x="170" y="194" width="30" height="6" rx="1"/><rect x="222" y="194" width="30" height="6" rx="1"/><rect x="278" y="194" width="30" height="6" rx="1"/><rect x="330" y="194" width="30" height="6" rx="1"/></g>'
155
+ // paper coming out (animated while printing)
156
+ + '<g clip-path="url(#hpSlot)"><g class="hp-sheet"><rect x="176" y="190" width="178" height="70" fill="var(--paper)" stroke="var(--line)"/>'
157
+ + '<g stroke="var(--ink-lines)" stroke-width="3" stroke-linecap="round"><line x1="192" y1="206" x2="300" y2="206"/><line x1="192" y1="218" x2="330" y2="218"/><line x1="192" y1="230" x2="280" y2="230"/></g></g></g>'
158
+ // output tray
159
+ + '<path d="M126 238 L404 238 L412 258 L118 258 Z" fill="var(--tray)" stroke="var(--line)"/>'
160
+ + '<rect x="230" y="242" width="70" height="5" rx="2.5" fill="var(--body-mid)"/>'
161
+ + '</svg>'
162
+
163
+ function statusInfo(p) {
164
+ if (!p.online || p.status === 'offline' || p.status === 'unknown') {
165
+ return {
166
+ cls: 'is-offline',
167
+ title: p.status === 'unknown'
168
+ ? 'Connecting…'
169
+ : 'Not connected.',
170
+ sub:
171
+ p.status === 'unknown'
172
+ ? 'Waiting for the first response from the printer'
173
+ : 'Check the printer is on and connected to the network',
174
+ }
175
+ }
176
+ const issues = (p.reasons || []).filter((r) => {
177
+ return /-(?:error|warning)$/.test(r) || r === 'media-empty' || r === 'media-jam' || r === 'door-open'
178
+ })
179
+ if (p.printing === true || (p.printing === undefined && p.status === 'processing')) {
180
+ return {
181
+ cls: 'is-printing',
182
+ title: 'Printing…',
183
+ sub: p.queuedJobs > 1
184
+ ? `${p.queuedJobs} jobs in the queue`
185
+ : 'Your document is on its way',
186
+ }
187
+ }
188
+ if (
189
+ p.status === 'stopped'
190
+ || issues.some((r) => {
191
+ return r.endsWith('-error')
192
+ })
193
+ ) {
194
+ return {
195
+ cls: 'is-error',
196
+ title: issues.length
197
+ ? reasonText(issues[0])
198
+ : 'Printer stopped',
199
+ sub: p.message || 'Check the printer',
200
+ }
201
+ }
202
+ if (issues.length) {
203
+ return {
204
+ cls: 'is-warn',
205
+ title: reasonText(issues[0]),
206
+ sub: issues.length > 1
207
+ ? `and ${issues.length - 1} more`
208
+ : 'Printer needs attention',
209
+ }
210
+ }
211
+ return { cls: 'is-ready', title: 'Ready', sub: 'Idle and waiting for jobs' }
212
+ }
213
+
214
+ const ICONS = {
215
+ 'is-printing':
216
+ '<svg viewBox="0 0 24 24"><path d="M7 3h10v5H7z" fill="none" stroke="currentColor" stroke-width="1.7"/><rect x="3" y="8" width="18" height="9" rx="2" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M7 14h10v7H7z" fill="none" stroke="currentColor" stroke-width="1.7"/></svg>',
217
+ 'is-ready':
218
+ '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M8 12.5l2.6 2.6L16 9.7" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>',
219
+ 'is-offline':
220
+ '<svg viewBox="0 0 24 24"><rect x="3" y="8" width="13" height="9" rx="2" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M6 3h7v5H6zM6 14h7v6H6z" fill="none" stroke="currentColor" stroke-width="1.7"/><circle cx="18" cy="17" r="4.5" fill="currentColor"/><path d="M16.8 15.8a1.3 1.3 0 1 1 1.8 1.2c-.4.2-.6.5-.6.9M18 19.6v.1" fill="none" stroke="var(--pill-bg)" stroke-width="1.2" stroke-linecap="round"/></svg>',
221
+ 'is-warn':
222
+ '<svg viewBox="0 0 24 24"><path d="M12 3l10 18H2z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M12 10v5M12 17.6v.1" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>',
223
+ 'is-error':
224
+ '<svg viewBox="0 0 24 24"><path d="M12 3l10 18H2z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M12 10v5M12 17.6v.1" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>',
225
+ }
226
+
227
+ function renderPrinter(p, threshold) {
228
+ const s = statusInfo(p)
229
+ const sec = el('section', `hp-card ${s.cls}`)
230
+
231
+ const left = el('div', 'hp-left')
232
+ left.appendChild(el('h1', 'hp-name', p.name))
233
+ if (p.model) {
234
+ left.appendChild(el('div', 'hp-model', p.model.replace(/^HP\s+/i, '')))
235
+ }
236
+
237
+ if (p.markers && p.markers.length) {
238
+ const box = el('div', 'hp-supplies')
239
+ box.appendChild(renderInk(p.markers, p.lowThreshold ?? threshold))
240
+ box.appendChild(el('div', 'hp-meta', 'Estimated supply levels'))
241
+ box.appendChild(el('div', 'hp-meta', `Last updated ${fmtTime(p.lastSeen || p.lastUpdated)}`))
242
+ const low = p.markers.filter((m) => {
243
+ return isLow(m, p.lowThreshold ?? threshold)
244
+ })
245
+ if (low.length) {
246
+ box.appendChild(
247
+ el(
248
+ 'div',
249
+ 'hp-warn',
250
+ `${
251
+ low.length === 1
252
+ ? `${low[0].name} is`
253
+ : `${low
254
+ .map((m) => {
255
+ return m.name
256
+ })
257
+ .join(' and ')} are`
258
+ } running low`,
259
+ ),
260
+ )
261
+ }
262
+ left.appendChild(box)
263
+ } else {
264
+ const empty = el('div', 'hp-supplies hp-supplies-empty')
265
+ empty.appendChild(
266
+ el(
267
+ 'div',
268
+ 'hp-meta',
269
+ p.online ? 'This printer did not report supply levels' : 'Supply levels appear once the printer is reachable',
270
+ ),
271
+ )
272
+ empty.appendChild(el('div', 'hp-meta', `Last updated ${fmtTime(p.lastUpdated)}`))
273
+ left.appendChild(empty)
274
+ }
275
+
276
+ if (p.energy) {
277
+ const e = p.energy
278
+ const kwh = e.kWh < 10
279
+ ? e.kWh.toFixed(2)
280
+ : e.kWh.toFixed(1)
281
+ const energy = el('div', 'hp-energy')
282
+ energy.title = 'Worked out from the printer\'s state (printing, ready, sleep, off) and the wattages in Settings'
283
+ energy.appendChild(el('strong', null, `≈ ${Math.round(e.watts * 10) / 10} W`))
284
+ energy.appendChild(
285
+ el(
286
+ 'span',
287
+ null,
288
+ ` now · ${kwh} kWh since ${new Date(e.since).toLocaleDateString([], { day: 'numeric', month: 'short' })}`,
289
+ ),
290
+ )
291
+ left.appendChild(energy)
292
+ }
293
+
294
+ const pill = el('div', 'hp-pill')
295
+ pill.setAttribute('role', 'status')
296
+ const icon = el('span', 'hp-pill-icon')
297
+ icon.innerHTML = ICONS[s.cls]
298
+ const txt = el('span', 'hp-pill-text')
299
+ txt.appendChild(el('strong', null, s.title))
300
+ txt.appendChild(el('span', null, ` ${s.sub}`))
301
+ pill.appendChild(icon)
302
+ pill.appendChild(txt)
303
+ left.appendChild(pill)
304
+
305
+ const right = el('div', 'hp-right')
306
+ right.innerHTML = PRINTER_SVG
307
+
308
+ sec.appendChild(left)
309
+ sec.appendChild(right)
310
+ return sec
311
+ }
312
+
313
+ function render(root, data) {
314
+ const printers = (data && data.printers) || []
315
+ root.innerHTML = ''
316
+ if (!printers.length) {
317
+ const e = el('section', 'hp-card hp-empty')
318
+ e.appendChild(el('h1', 'hp-name', 'No printers yet'))
319
+ e.appendChild(el('div', 'hp-meta', 'Add a printer in the plugin settings and restart Homebridge.'))
320
+ root.appendChild(e)
321
+ return
322
+ }
323
+ printers.forEach((p) => {
324
+ root.appendChild(renderPrinter(p, 10))
325
+ })
326
+ }
327
+
328
+ /**
329
+ * source: { load(): Promise<status>, subscribe?(cb): void }
330
+ */
331
+ function mount(root, source) {
332
+ root.classList.add('hp-dashboard')
333
+ root.innerHTML = '<div class="hp-loading">Loading printers…</div>'
334
+ let lastSig = null
335
+ const draw = function (d) {
336
+ // Only rebuild when something visible changed, so animations don't restart every poll
337
+ const sig = JSON.stringify(
338
+ ((d && d.printers) || []).map((p) => {
339
+ return [
340
+ p.name,
341
+ p.model,
342
+ p.online,
343
+ p.status,
344
+ p.printing,
345
+ p.energy && [
346
+ p.energy.watts,
347
+ p.energy.kWh.toFixed(2),
348
+ ],
349
+ p.reasons,
350
+ p.queuedJobs,
351
+ p.markers,
352
+ p.message,
353
+ fmtTime(p.lastSeen || p.lastUpdated),
354
+ ]
355
+ }),
356
+ )
357
+ if (sig === lastSig) {
358
+ return
359
+ }
360
+ lastSig = sig
361
+ render(root, d)
362
+ }
363
+ const fail = function (err) {
364
+ root.innerHTML = ''
365
+ const e = el('section', 'hp-card hp-empty')
366
+ e.appendChild(el('h1', 'hp-name', 'Dashboard unavailable'))
367
+ e.appendChild(el('div', 'hp-meta', (err && err.message) || String(err)))
368
+ root.appendChild(e)
369
+ }
370
+ source.load().then(draw, fail)
371
+ if (source.subscribe) {
372
+ source.subscribe(draw, fail)
373
+ }
374
+ }
375
+
376
+ /** Data source for the standalone page: Server-Sent Events, falling back to polling. */
377
+ function httpSource(base) {
378
+ base = base || ''
379
+ return {
380
+ load() {
381
+ return fetch(`${base}/api/status`, { cache: 'no-store' }).then((r) => {
382
+ if (!r.ok) {
383
+ throw new Error(`HTTP ${r.status}`)
384
+ }
385
+ return r.json()
386
+ })
387
+ },
388
+ subscribe(cb) {
389
+ const self = this
390
+ const poll = function () {
391
+ setInterval(() => {
392
+ self.load().then(cb, () => {})
393
+ }, 5000)
394
+ }
395
+ if (!global.EventSource) {
396
+ return poll()
397
+ }
398
+ const es = new EventSource(`${base}/api/events`)
399
+ let opened = false
400
+ es.onopen = function () {
401
+ opened = true
402
+ }
403
+ es.onmessage = function (e) {
404
+ try {
405
+ cb(JSON.parse(e.data))
406
+ } catch (_) {
407
+ /* ignore */
408
+ }
409
+ }
410
+ es.onerror = function () {
411
+ if (!opened) {
412
+ es.close()
413
+ poll()
414
+ }
415
+ }
416
+ },
417
+ }
418
+ }
419
+
420
+ global.HPDashboard = { mount, render, httpSource }
421
+ })(window)
@@ -0,0 +1,20 @@
1
+ <link rel="stylesheet" href="dashboard.css">
2
+ <link rel="stylesheet" href="settings.css">
3
+
4
+ <div id="hp-ui">
5
+ <div id="hp-dash"></div>
6
+
7
+ <div class="hp-tools">
8
+ <div class="hp-tools-row">
9
+ <button type="button" class="btn btn-primary" id="hp-find">Find printers on my network</button>
10
+ <a id="hp-full" class="btn btn-outline-secondary" target="_blank" rel="noopener" style="display:none">Open full-screen dashboard</a>
11
+ </div>
12
+ <div id="hp-found"></div>
13
+ <div id="hp-tests"></div>
14
+ </div>
15
+
16
+ <p class="hp-hint text-muted small" id="hp-hint">Enter your printer's IP address or .local name below, click <strong>Save</strong>, then restart Homebridge.</p>
17
+ </div>
18
+
19
+ <script src="dashboard.js"></script>
20
+ <script src="settings.js"></script>
@@ -0,0 +1,30 @@
1
+ /* Printer page in the Homebridge UI – uses the Bootstrap 5 styles the Homebridge UI injects */
2
+ #hp-ui { padding-bottom: 6px; }
3
+ #hp-dash { margin-bottom: 14px; }
4
+ #hp-dash .hp-card { padding: 22px 26px; }
5
+ #hp-dash .hp-name { font-size: clamp(28px, 5vw, 44px); }
6
+ #hp-dash .hp-model { font-size: clamp(15px, 2vw, 21px); margin-bottom: 16px; }
7
+ #hp-dash .hp-printer-svg { max-width: 460px; }
8
+ .hp-tools { margin-bottom: 8px; }
9
+ .hp-tools-row { display: flex; flex-wrap: wrap; gap: 8px; }
10
+ #hp-found { margin-top: 10px; }
11
+ .hp-found-row {
12
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
13
+ padding: 10px 12px; margin-bottom: 8px;
14
+ border: 1px solid var(--bs-border-color, #dee2e6); border-radius: 8px;
15
+ }
16
+ #hp-tests { margin-top: 12px; }
17
+ .hp-test-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 0 2px; flex-wrap: wrap; }
18
+ .hp-test-label { overflow-wrap: anywhere; }
19
+ .hp-test-result { font-size: 14px; min-height: 2px; padding-bottom: 6px; }
20
+ .hp-test-result.is-good { color: var(--bs-success, #198754); }
21
+ .hp-test-result.is-bad { color: var(--bs-danger, #dc3545); }
22
+ .hp-test-result.is-busy { color: var(--bs-secondary-color, #6c757d); }
23
+ .hp-hint { margin: 10px 0 0; }
24
+
25
+ /* Readable in the Homebridge UI's dark mode, whatever theme is active */
26
+ body.dark-mode #hp-ui { color: #e8e8ef; }
27
+ body.dark-mode #hp-ui .text-muted { color: #a8a8b8 !important; }
28
+ body.dark-mode #hp-ui .btn-outline-secondary { color: #d6d6e0; border-color: #8a8a9a; }
29
+ body.dark-mode #hp-ui .btn-outline-secondary:hover { color: #111; background: #d6d6e0; }
30
+ body.dark-mode .hp-found-row { border-color: #4a4a58; }