homebridge-generac 0.1.0-beta.1

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.
Files changed (71) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +200 -0
  3. package/NOTICE +20 -0
  4. package/README.md +179 -0
  5. package/SECURITY.md +17 -0
  6. package/config.schema.json +133 -0
  7. package/dist/accessory.d.ts +54 -0
  8. package/dist/accessory.js +191 -0
  9. package/dist/accessory.js.map +1 -0
  10. package/dist/api.d.ts +43 -0
  11. package/dist/api.js +124 -0
  12. package/dist/api.js.map +1 -0
  13. package/dist/attention.d.ts +18 -0
  14. package/dist/attention.js +40 -0
  15. package/dist/attention.js.map +1 -0
  16. package/dist/auth.d.ts +56 -0
  17. package/dist/auth.js +412 -0
  18. package/dist/auth.js.map +1 -0
  19. package/dist/captures.d.ts +9 -0
  20. package/dist/captures.js +42 -0
  21. package/dist/captures.js.map +1 -0
  22. package/dist/cli.d.ts +2 -0
  23. package/dist/cli.js +116 -0
  24. package/dist/cli.js.map +1 -0
  25. package/dist/exercise.d.ts +42 -0
  26. package/dist/exercise.js +115 -0
  27. package/dist/exercise.js.map +1 -0
  28. package/dist/files.d.ts +5 -0
  29. package/dist/files.js +28 -0
  30. package/dist/files.js.map +1 -0
  31. package/dist/index.d.ts +3 -0
  32. package/dist/index.js +6 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/model.d.ts +74 -0
  35. package/dist/model.js +167 -0
  36. package/dist/model.js.map +1 -0
  37. package/dist/platform.d.ts +117 -0
  38. package/dist/platform.js +596 -0
  39. package/dist/platform.js.map +1 -0
  40. package/dist/settings.d.ts +75 -0
  41. package/dist/settings.js +115 -0
  42. package/dist/settings.js.map +1 -0
  43. package/dist/state.d.ts +28 -0
  44. package/dist/state.js +43 -0
  45. package/dist/state.js.map +1 -0
  46. package/dist/types.d.ts +98 -0
  47. package/dist/types.js +48 -0
  48. package/dist/types.js.map +1 -0
  49. package/dist/ui/server.d.ts +96 -0
  50. package/dist/ui/server.js +328 -0
  51. package/dist/ui/server.js.map +1 -0
  52. package/homebridge-ui/public/generac-banner.png +0 -0
  53. package/homebridge-ui/public/generac.css +255 -0
  54. package/homebridge-ui/public/index.css +510 -0
  55. package/homebridge-ui/public/index.html +14 -0
  56. package/homebridge-ui/public/js/api.js +31 -0
  57. package/homebridge-ui/public/js/app.js +1 -0
  58. package/homebridge-ui/public/js/card.js +27 -0
  59. package/homebridge-ui/public/js/copy.js +167 -0
  60. package/homebridge-ui/public/js/dom.js +247 -0
  61. package/homebridge-ui/public/js/footer.js +26 -0
  62. package/homebridge-ui/public/js/format.js +62 -0
  63. package/homebridge-ui/public/js/main.js +288 -0
  64. package/homebridge-ui/public/js/mark.js +34 -0
  65. package/homebridge-ui/public/js/model.js +120 -0
  66. package/homebridge-ui/public/js/sections/account.js +272 -0
  67. package/homebridge-ui/public/js/sections/generators.js +165 -0
  68. package/homebridge-ui/public/js/sections/settings.js +114 -0
  69. package/homebridge-ui/public/js/validate.js +45 -0
  70. package/homebridge-ui/server.js +8 -0
  71. package/package.json +70 -0
@@ -0,0 +1,272 @@
1
+ /**
2
+ * The Mobile Link account card in its four states (SPEC section 11.3 B) and the two-step Connect flow that
3
+ * replaces it in place (11.3 C). The card is drawn from /status and the page state on every redraw; the flow
4
+ * hands control back to it when it ends. The password and the code live in the flow state until the request
5
+ * that carries them returns, and are never written anywhere.
6
+ */
7
+ import { callServer } from '../api.js';
8
+ import { badge, card, cardName } from '../card.js';
9
+ import { ACCOUNT, CONNECT } from '../copy.js';
10
+ import { el, grid, gridCell, inlineConfirm, linkButton, outlineButton, paragraph, passwordField, primaryButton, statusBox, textField, } from '../dom.js';
11
+ import { parseDate, relativeTime } from '../format.js';
12
+ /** Checking this long after the sign-in, the card adds the restart line (SPEC section 11.3 B). */
13
+ export const CHECKING_HINT_MS = 120 * 1000;
14
+ function startFlow(app) {
15
+ app.ui.flow = { step: 'credentials', email: '', password: '', error: null, busy: false };
16
+ app.rerender('account');
17
+ }
18
+ /** Not connected; before the first /status answer the Connect button waits disabled. */
19
+ function notConnectedCard(app, ready) {
20
+ const connect = primaryButton(ACCOUNT.connect, () => startFlow(app));
21
+ connect.disabled = !ready;
22
+ return card({
23
+ title: cardName(ACCOUNT.title),
24
+ body: [paragraph(ACCOUNT.notConnectedBody, 'gn-body'), el('div', { class: 'gn-actions' }, connect)],
25
+ cls: 'gn-account-card',
26
+ });
27
+ }
28
+ /** Signed in, and the platform has not polled with the new credentials yet: no button, one line, then the restart line. */
29
+ function checkingCard(app, email) {
30
+ const since = app.ui.checkingSince;
31
+ const slow = since !== null && Date.now() - since >= CHECKING_HINT_MS;
32
+ return card({
33
+ title: cardName(ACCOUNT.title),
34
+ badges: [badge(ACCOUNT.checking, 'outline')],
35
+ body: [
36
+ email ? el('div', { class: 'gn-row' }, el('span', { class: 'gn-email' }, email)) : null,
37
+ paragraph(ACCOUNT.checkingBody, 'gn-body'),
38
+ slow ? paragraph(ACCOUNT.checkingSlow, 'gn-body') : null,
39
+ ],
40
+ cls: 'gn-account-card',
41
+ });
42
+ }
43
+ function connectedCard(app, email, lastChecked) {
44
+ const checked = parseDate(lastChecked);
45
+ const disconnect = inlineConfirm({
46
+ start: linkButton(ACCOUNT.disconnect, () => undefined, 'gn-disconnect'),
47
+ question: ACCOUNT.disconnectQuestion,
48
+ confirmLabel: ACCOUNT.disconnect,
49
+ confirmClass: 'btn btn-danger btn-sm',
50
+ cancelLabel: ACCOUNT.keep,
51
+ open: app.ui.disconnectOpen,
52
+ onOpen: (open) => {
53
+ app.ui.disconnectOpen = open;
54
+ },
55
+ onConfirm: () => {
56
+ void callServer('/disconnect').then(() => app.refreshStatus());
57
+ if (app.status) {
58
+ app.status.account = { state: 'not_connected' };
59
+ }
60
+ app.rerender('account');
61
+ },
62
+ });
63
+ return card({
64
+ title: cardName(ACCOUNT.title),
65
+ badges: [badge(ACCOUNT.connected, 'status')],
66
+ body: [
67
+ el('div', { class: 'gn-row' }, el('span', { class: 'gn-email' }, email)),
68
+ checked ? el('p', { class: 'gn-meta' }, ACCOUNT.lastChecked(relativeTime(checked))) : null,
69
+ ],
70
+ footerRight: disconnect,
71
+ cls: 'gn-account-card',
72
+ });
73
+ }
74
+ function reconnectCard(app, email) {
75
+ return card({
76
+ title: cardName(ACCOUNT.title),
77
+ badges: [badge(ACCOUNT.reconnectBadge, 'warn')],
78
+ body: [
79
+ email ? el('div', { class: 'gn-row' }, el('span', { class: 'gn-email' }, email)) : null,
80
+ paragraph(ACCOUNT.reconnectBody, 'gn-body'),
81
+ app.config.attentionSensor ? paragraph(ACCOUNT.reconnectAttention, 'gn-body') : null,
82
+ el('div', { class: 'gn-actions' }, primaryButton(ACCOUNT.reconnect, () => startFlow(app))),
83
+ ],
84
+ cls: 'gn-account-card',
85
+ });
86
+ }
87
+ // ---------------------------------------------------------------------------
88
+ // Connect flow (SPEC section 11.3 C)
89
+ // ---------------------------------------------------------------------------
90
+ function endFlow(app) {
91
+ void callServer('/connect/cancel');
92
+ app.ui.flow = null;
93
+ app.rerender('account');
94
+ }
95
+ /**
96
+ * The sign-in finished: the flow ends and the card renderer takes over. The platform picks the credentials up
97
+ * within a minute, so the card reads Checking until /status says otherwise; every /status answer redraws it.
98
+ */
99
+ function finishFlow(app, email) {
100
+ app.ui.flow = null;
101
+ app.ui.checkingSince = Date.now();
102
+ if (app.status) {
103
+ app.status.account = { state: 'checking', email };
104
+ }
105
+ app.rerender('account');
106
+ void app.refreshStatus();
107
+ }
108
+ const START_ERRORS = {
109
+ wrong_password: CONNECT.wrongPassword,
110
+ unknown_email: CONNECT.unknownEmail,
111
+ network: CONNECT.network,
112
+ };
113
+ const CODE_ERRORS = {
114
+ wrong_code: CONNECT.wrongCode,
115
+ too_many: CONNECT.tooMany,
116
+ expired: CONNECT.expired,
117
+ };
118
+ function credentialsCard(app, flow) {
119
+ const email = textField(CONNECT.email, flow.email, (v) => {
120
+ flow.email = v;
121
+ }, { path: 'connect.email', type: 'email', required: true, placeholder: CONNECT.emailPlaceholder, autocomplete: 'username', inputmode: 'email' });
122
+ const password = passwordField(CONNECT.password, flow.password, (v) => {
123
+ flow.password = v;
124
+ }, { path: 'connect.password', required: true, help: CONNECT.passwordHelp });
125
+ const signIn = primaryButton(flow.busy ? CONNECT.signingIn : CONNECT.signIn, () => {
126
+ if (flow.busy) {
127
+ return;
128
+ }
129
+ const missing = [!flow.email.trim() ? 'connect.email' : null, !flow.password ? 'connect.password' : null].filter((p) => p !== null);
130
+ if (missing.length > 0) {
131
+ for (const path of missing) {
132
+ app.touch(path);
133
+ }
134
+ return;
135
+ }
136
+ flow.busy = true;
137
+ flow.error = null;
138
+ app.rerender('account');
139
+ const submitted = { email: flow.email.trim(), password: flow.password };
140
+ void callServer('/connect/start', submitted).then((r) => {
141
+ if (app.ui.flow !== flow) {
142
+ return;
143
+ }
144
+ flow.busy = false;
145
+ if (r && 'step' in r) {
146
+ if (r.step === 'done') {
147
+ finishFlow(app, submitted.email);
148
+ return;
149
+ }
150
+ app.ui.flow = { step: 'code', method: r.method, email: submitted.email, code: '', error: null, busy: false };
151
+ }
152
+ else if (r && 'error' in r && r.error === 'unsupported_factor') {
153
+ app.ui.flow = { step: 'unsupported' };
154
+ }
155
+ else {
156
+ const code = r && 'error' in r ? r.error : 'network';
157
+ flow.error = code in START_ERRORS ? code : 'network';
158
+ flow.password = '';
159
+ }
160
+ app.rerender('account');
161
+ });
162
+ });
163
+ signIn.disabled = flow.busy;
164
+ const cancel = linkButton(CONNECT.cancel, () => endFlow(app));
165
+ cancel.disabled = flow.busy;
166
+ return card({
167
+ title: cardName(ACCOUNT.title),
168
+ body: [
169
+ grid(gridCell(6, email), gridCell(6, password)),
170
+ flow.error ? statusBox('danger', START_ERRORS[flow.error]) : null,
171
+ el('div', { class: 'gn-actions' }, signIn, cancel),
172
+ ],
173
+ cls: 'gn-account-card gn-connect',
174
+ });
175
+ }
176
+ function codeCard(app, flow) {
177
+ const body = flow.method === 'sms' ? CONNECT.codeBodySms : flow.method === 'otp' ? CONNECT.codeBodyOtp : CONNECT.codeBodyEmail(flow.email);
178
+ const locked = flow.error === 'too_many' || flow.error === 'expired';
179
+ const code = textField(CONNECT.code, flow.code, (v) => {
180
+ flow.code = v;
181
+ }, { path: 'connect.code', required: true, monospace: true, placeholder: CONNECT.codePlaceholder, autocomplete: 'one-time-code', inputmode: 'numeric' });
182
+ const proceed = primaryButton(CONNECT.continue, () => {
183
+ if (flow.busy || locked) {
184
+ return;
185
+ }
186
+ if (!flow.code.trim()) {
187
+ app.touch('connect.code');
188
+ return;
189
+ }
190
+ flow.busy = true;
191
+ flow.error = null;
192
+ app.rerender('account');
193
+ const submitted = flow.code.trim();
194
+ void callServer('/connect/code', { code: submitted }).then((r) => {
195
+ if (app.ui.flow !== flow) {
196
+ return;
197
+ }
198
+ flow.busy = false;
199
+ flow.code = '';
200
+ app.untouch('connect.code');
201
+ if (r && 'step' in r && r.step === 'done') {
202
+ finishFlow(app, flow.email);
203
+ return;
204
+ }
205
+ const error = r && 'error' in r ? r.error : 'expired';
206
+ flow.error = error in CODE_ERRORS ? error : 'expired';
207
+ app.rerender('account');
208
+ });
209
+ });
210
+ proceed.disabled = flow.busy || locked;
211
+ const cancel = linkButton(CONNECT.cancel, () => endFlow(app));
212
+ cancel.disabled = flow.busy;
213
+ const node = card({
214
+ title: cardName(CONNECT.codeTitle),
215
+ body: [
216
+ paragraph(body, 'gn-body'),
217
+ paragraph(CONNECT.codeSecondLine, 'gn-body'),
218
+ grid(gridCell(4, code)),
219
+ flow.error ? statusBox('danger', CODE_ERRORS[flow.error]) : null,
220
+ el('div', { class: 'gn-actions' }, proceed, cancel),
221
+ ],
222
+ cls: 'gn-account-card gn-connect',
223
+ });
224
+ if (!flow.busy && !locked) {
225
+ window.setTimeout(() => node.querySelector('input')?.focus(), 0);
226
+ }
227
+ return node;
228
+ }
229
+ function unsupportedCard(app) {
230
+ return card({
231
+ title: cardName(ACCOUNT.title),
232
+ body: [
233
+ paragraph(CONNECT.unsupported, 'gn-body'),
234
+ el('div', { class: 'gn-actions' }, outlineButton(CONNECT.cancel, () => endFlow(app))),
235
+ ],
236
+ cls: 'gn-account-card gn-connect',
237
+ });
238
+ }
239
+ function renderFlow(app, flow) {
240
+ switch (flow.step) {
241
+ case 'credentials':
242
+ return credentialsCard(app, flow);
243
+ case 'code':
244
+ return codeCard(app, flow);
245
+ default:
246
+ return unsupportedCard(app);
247
+ }
248
+ }
249
+ export function renderAccount(app, container) {
250
+ if (app.ui.flow) {
251
+ container.appendChild(renderFlow(app, app.ui.flow));
252
+ return;
253
+ }
254
+ const account = app.status?.account;
255
+ if (!account) {
256
+ container.appendChild(notConnectedCard(app, false));
257
+ return;
258
+ }
259
+ switch (account.state) {
260
+ case 'connected':
261
+ container.appendChild(connectedCard(app, account.email ?? '', account.lastChecked));
262
+ break;
263
+ case 'reconnect_needed':
264
+ container.appendChild(reconnectCard(app, account.email ?? ''));
265
+ break;
266
+ case 'checking':
267
+ container.appendChild(checkingCard(app, account.email ?? ''));
268
+ break;
269
+ default:
270
+ container.appendChild(notConnectedCard(app, true));
271
+ }
272
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The Generators section (SPEC section 11.3 D): one card per generator with its status badges, fault reasons,
3
+ * the detail rows, and Rename in the footer editing in place; the empty line; and the "Also on your account"
4
+ * lines for the devices the plugin skips.
5
+ */
6
+ import { badge, card } from '../card.js';
7
+ import { GENERATOR, INTRO, SHELL } from '../copy.js';
8
+ import { button, el, footerAction, linkButton, paragraph, textField } from '../dom.js';
9
+ import { formatDate, formatTime, formatTimeOrDate, formatVolts, hhmmTo12h, parseDate, relativeTime } from '../format.js';
10
+ import { overrideFor, setOverride } from '../model.js';
11
+ const STATUS = { READY: 1, RUNNING: 2, EXERCISING: 3 };
12
+ const DEVICE = { PROPANE_MONITOR: 2, LINKED_ECOBEE: 7 };
13
+ export function displayName(app, g) {
14
+ return overrideFor(app.config, g.id) ?? g.name;
15
+ }
16
+ /** The status badges of a card (SPEC section 11.3 D). */
17
+ export function statusBadges(g) {
18
+ if (!g.connected) {
19
+ return [[GENERATOR.notResponding, 'muted']];
20
+ }
21
+ const out = [];
22
+ if (g.status === STATUS.RUNNING) {
23
+ out.push([GENERATOR.running, 'running']);
24
+ }
25
+ else if (g.status === STATUS.EXERCISING) {
26
+ out.push([GENERATOR.exercising, 'exercising']);
27
+ }
28
+ else if (g.status === STATUS.READY) {
29
+ out.push([g.statusLabel || GENERATOR.ready, 'ready']);
30
+ }
31
+ else if (!g.fault && g.statusLabel) {
32
+ out.push([g.statusLabel, 'muted']);
33
+ }
34
+ if (g.fault) {
35
+ out.push([GENERATOR.fault, 'fault']);
36
+ }
37
+ return out;
38
+ }
39
+ function row(label, ...value) {
40
+ return [el('dt', {}, label), el('dd', {}, ...value)];
41
+ }
42
+ /** The inline Name field with Save name and Cancel (SPEC section 11.3 D); "Name is required." on blur. */
43
+ function renameEditor(app, g, rename, titleNode) {
44
+ const path = `generators[${g.id}].name`;
45
+ const field = textField(GENERATOR.name, rename.value, (v) => {
46
+ rename.value = v;
47
+ titleNode.textContent = v.trim() || g.name;
48
+ }, { path, required: true });
49
+ const feedback = field.querySelector('.invalid-feedback');
50
+ const input = field.querySelector('input');
51
+ const showRequired = () => {
52
+ const missing = !rename.value.trim();
53
+ field.classList.toggle('has-issue', missing);
54
+ input.classList.toggle('is-invalid', missing);
55
+ if (feedback) {
56
+ feedback.textContent = missing ? SHELL.required(GENERATOR.name) : '';
57
+ }
58
+ return missing;
59
+ };
60
+ const save = button(GENERATOR.saveName, () => {
61
+ if (showRequired()) {
62
+ input.focus();
63
+ return;
64
+ }
65
+ setOverride(app.config, g.id, rename.value, g.name);
66
+ app.ui.rename = null;
67
+ app.changed();
68
+ app.rerender('generators');
69
+ }, 'btn btn-primary btn-sm');
70
+ const cancel = linkButton(GENERATOR.cancel, () => {
71
+ app.ui.rename = null;
72
+ app.rerender('generators');
73
+ });
74
+ input.addEventListener('blur', () => showRequired());
75
+ input.addEventListener('input', () => {
76
+ if (rename.value.trim()) {
77
+ showRequired();
78
+ }
79
+ });
80
+ input.addEventListener('keydown', (event) => {
81
+ if (event.key === 'Enter') {
82
+ event.preventDefault();
83
+ save.click();
84
+ }
85
+ else if (event.key === 'Escape') {
86
+ event.preventDefault();
87
+ cancel.click();
88
+ }
89
+ });
90
+ window.setTimeout(() => {
91
+ input.focus();
92
+ input.select();
93
+ }, 0);
94
+ return el('span', { class: 'gn-rename-editor' }, field, el('span', { class: 'gn-rename-actions' }, save, cancel));
95
+ }
96
+ function generatorCard(app, g) {
97
+ const name = displayName(app, g);
98
+ const title = el('span', { class: 'gn-title-block' }, el('span', { class: 'ns-card-name' }, name), el('span', { class: 'gn-model' }, g.model), el('span', { class: 'gn-serial' }, GENERATOR.serial(g.serial)));
99
+ const badges = g.maintenanceDue ? [badge(GENERATOR.maintenanceDue, 'warn')] : [];
100
+ const statusRow = el('div', { class: 'gn-status' }, ...statusBadges(g).map(([text, kind]) => badge(text, kind)));
101
+ const lastSeen = parseDate(g.lastSeen);
102
+ if (!g.connected) {
103
+ statusRow.appendChild(el('span', { class: 'gn-note' }, GENERATOR.notRespondingNote(lastSeen ? formatTimeOrDate(lastSeen) : '?')));
104
+ }
105
+ else if (g.fault && g.faultReasons.length > 0) {
106
+ statusRow.appendChild(el('ul', { class: 'gn-reasons' }, ...g.faultReasons.map((r) => el('li', {}, r))));
107
+ }
108
+ const rows = [];
109
+ if (g.batteryVoltage !== null) {
110
+ const low = g.batteryVoltage <= app.config.batteryLowVoltage;
111
+ rows.push(...row(GENERATOR.battery, GENERATOR.volts(formatVolts(g.batteryVoltage)), low ? badge(GENERATOR.low, 'warn') : null));
112
+ }
113
+ if (g.fuelType === 'propane' && typeof g.fuelPercent === 'number') {
114
+ rows.push(...row(GENERATOR.fuel, GENERATOR.percent(String(Math.round(g.fuelPercent))), el('span', { class: 'gn-help' }, GENERATOR.fuelHelp)));
115
+ }
116
+ if (g.engineHours !== null) {
117
+ rows.push(...row(GENERATOR.engineHours, GENERATOR.hours(String(g.engineHours))));
118
+ }
119
+ const exercise = hhmmTo12h(app.config.exerciseTime) ?? hhmmTo12h(g.exerciseTimeFromApi) ?? g.exerciseTime;
120
+ if (exercise) {
121
+ rows.push(...row(GENERATOR.exerciseTime, GENERATOR.weekly(exercise)));
122
+ }
123
+ const lastExercise = parseDate(g.lastExerciseAt);
124
+ if (lastExercise) {
125
+ rows.push(...row(GENERATOR.lastExercise, GENERATOR.dateAt(formatDate(lastExercise), formatTime(lastExercise))));
126
+ }
127
+ if (lastSeen) {
128
+ rows.push(...row(GENERATOR.lastSeen, relativeTime(lastSeen)));
129
+ }
130
+ const footer = el('span', { class: 'gn-rename' });
131
+ const rename = app.ui.rename;
132
+ if (rename && rename.id === g.id) {
133
+ footer.appendChild(renameEditor(app, g, rename, title.firstElementChild));
134
+ }
135
+ else {
136
+ footer.appendChild(footerAction(GENERATOR.rename, () => {
137
+ app.ui.rename = { id: g.id, value: name };
138
+ app.rerender('generators');
139
+ }));
140
+ }
141
+ return card({
142
+ title,
143
+ badges,
144
+ body: [statusRow, rows.length > 0 ? el('dl', { class: 'gn-kv' }, ...rows) : null],
145
+ footerRight: footer,
146
+ cls: 'gn-generator-card',
147
+ });
148
+ }
149
+ export function renderGenerators(app, container) {
150
+ const generators = app.status?.generators ?? [];
151
+ if (generators.length === 0) {
152
+ container.appendChild(paragraph(INTRO.generatorsEmpty, 'gn-empty'));
153
+ }
154
+ for (const g of generators) {
155
+ container.appendChild(generatorCard(app, g));
156
+ }
157
+ for (const other of app.status?.others ?? []) {
158
+ if (other.type === DEVICE.PROPANE_MONITOR) {
159
+ container.appendChild(paragraph(GENERATOR.alsoPropane, 'gn-also'));
160
+ }
161
+ else if (other.type === DEVICE.LINKED_ECOBEE) {
162
+ container.appendChild(paragraph(GENERATOR.alsoEcobee(other.name), 'gn-also'));
163
+ }
164
+ }
165
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The Settings section (SPEC section 11.3 E): one collapsed disclosure holding every platform setting, and the
3
+ * Reset dialog. Validation runs on blur through the page; the messages come from copy.ts.
4
+ */
5
+ import { callServer, toastSuccess } from '../api.js';
6
+ import { SETTINGS, SHELL } from '../copy.js';
7
+ import { button, checkboxField, dangerLinkButton, disclosure, el, grid, gridCell, inlineDialog, linkButton, numberField, reveal, textField, } from '../dom.js';
8
+ import { formatVolts } from '../format.js';
9
+ import { DEFAULTS, emptyConfig } from '../model.js';
10
+ import { hasOneDecimal } from '../validate.js';
11
+ /**
12
+ * The Reset dialog: the three lines from SPEC section 11.3 E, "Type RESET to confirm.", Confirm disabled until
13
+ * typed. It renders inline directly below the Reset link (SPEC section 11.2) and is page state, so a redraw of
14
+ * the section keeps it open; opening it by click focuses the field and scrolls the host modal to the dialog.
15
+ */
16
+ function resetDialog(app, opened) {
17
+ const confirmInput = el('input', { type: 'text', class: 'form-control', autocomplete: 'off', spellcheck: 'false', id: 'gn-reset-confirm' });
18
+ let close = () => undefined;
19
+ const confirm = button(SHELL.resetConfirm, () => {
20
+ close();
21
+ // Sign out and forget the saved state on the server; the platform removes the accessories on its next start.
22
+ void callServer('/reset').then(() => app.refreshStatus());
23
+ if (app.status) {
24
+ app.status.account = { state: 'not_connected' };
25
+ }
26
+ app.ui.flow = null;
27
+ app.ui.disconnectOpen = false;
28
+ app.ui.rename = null;
29
+ app.replaceConfig(emptyConfig());
30
+ toastSuccess(SHELL.resetDone);
31
+ }, 'btn btn-danger btn-sm');
32
+ confirm.disabled = true;
33
+ confirmInput.addEventListener('input', () => {
34
+ confirm.disabled = confirmInput.value.trim() !== 'RESET';
35
+ });
36
+ const dialog = inlineDialog({
37
+ title: SHELL.resetTitle,
38
+ body: el('div', {}, el('ul', { class: 'ps-3' }, ...SETTINGS.resetLines.map((line) => el('li', {}, line))), el('label', { class: 'form-label', for: 'gn-reset-confirm' }, SHELL.resetPrompt), confirmInput),
39
+ actions: [confirm, linkButton(SHELL.resetCancel, () => close())],
40
+ onClose: () => {
41
+ app.ui.resetOpen = false;
42
+ },
43
+ });
44
+ close = dialog.close;
45
+ if (opened) {
46
+ window.setTimeout(() => {
47
+ confirmInput.focus();
48
+ reveal(dialog.el);
49
+ }, 0);
50
+ }
51
+ return dialog.el;
52
+ }
53
+ /** The Reset link, with the dialog directly below it while open. */
54
+ function resetControl(app) {
55
+ const holder = el('div', { class: 'gn-reset' });
56
+ const link = dangerLinkButton(SHELL.reset, () => {
57
+ if (app.ui.resetOpen) {
58
+ return;
59
+ }
60
+ app.ui.resetOpen = true;
61
+ holder.appendChild(resetDialog(app, true));
62
+ });
63
+ holder.appendChild(link);
64
+ if (app.ui.resetOpen) {
65
+ holder.appendChild(resetDialog(app, false));
66
+ }
67
+ return holder;
68
+ }
69
+ export function renderSettings(app, container) {
70
+ const c = app.config;
71
+ const fields = grid(gridCell(6, textField(SETTINGS.name, c.name, (v) => {
72
+ c.name = v;
73
+ app.changed();
74
+ }, { path: 'name', required: true })), gridCell(6), gridCell(6, numberField(SETTINGS.pollIdle, c.pollIdleMinutes, (v) => {
75
+ c.pollIdleMinutes = v;
76
+ app.changed();
77
+ }, { path: 'pollIdleMinutes', min: DEFAULTS.pollIdleMinutesMin, help: SETTINGS.pollIdleHelp })), gridCell(6, numberField(SETTINGS.pollActive, c.pollActiveSeconds, (v) => {
78
+ c.pollActiveSeconds = v;
79
+ app.changed();
80
+ }, { path: 'pollActiveSeconds', min: DEFAULTS.pollActiveSecondsMin, help: SETTINGS.pollActiveHelp })), gridCell(6, numberField(SETTINGS.batteryLow, c.batteryLowVoltage, (v) => {
81
+ c.batteryLowVoltage = v;
82
+ app.changed();
83
+ }, {
84
+ path: 'batteryLowVoltage', step: '0.1', help: SETTINGS.batteryLowHelp,
85
+ // One decimal place on load and after blur (12.0, 11.8); more than one keeps its text and its message (SPEC section 11.3 E).
86
+ format: (v) => (hasOneDecimal(v) ? formatVolts(v) : null),
87
+ })), gridCell(6), gridCell(12, checkboxField(SETTINGS.faultOnStopped, c.faultOnStopped, (v) => {
88
+ c.faultOnStopped = v;
89
+ app.changed();
90
+ }, { path: 'faultOnStopped', help: SETTINGS.faultOnStoppedHelp })), gridCell(12, checkboxField(SETTINGS.faultOnDisconnected, c.faultOnDisconnected, (v) => {
91
+ c.faultOnDisconnected = v;
92
+ app.changed();
93
+ }, { path: 'faultOnDisconnected', help: SETTINGS.faultOnDisconnectedHelp })), gridCell(12, checkboxField(SETTINGS.attentionSensor, c.attentionSensor, (v) => {
94
+ c.attentionSensor = v;
95
+ app.changed();
96
+ // The Reconnect needed card mentions the sensor only while it is on.
97
+ app.rerender('account');
98
+ }, { path: 'attentionSensor', help: SETTINGS.attentionSensorHelp })), gridCell(12, checkboxField(SETTINGS.exerciseSensor, c.exerciseSensor, (v) => {
99
+ c.exerciseSensor = v;
100
+ app.changed();
101
+ }, { path: 'exerciseSensor', help: SETTINGS.exerciseSensorHelp })), gridCell(6, textField(SETTINGS.exerciseTime, c.exerciseTime, (v) => {
102
+ c.exerciseTime = v;
103
+ app.changed();
104
+ // The generator card's Exercise time row shows the configured value.
105
+ app.rerender('generators');
106
+ }, { path: 'exerciseTime', placeholder: SETTINGS.exerciseTimePlaceholder, help: SETTINGS.exerciseTimeHelp, inputmode: 'numeric', monospace: true })), gridCell(6, numberField(SETTINGS.exerciseHold, c.exerciseHoldMinutes, (v) => {
107
+ c.exerciseHoldMinutes = v;
108
+ app.changed();
109
+ }, { path: 'exerciseHoldMinutes', min: DEFAULTS.exerciseHoldMinutesMin, help: SETTINGS.exerciseHoldHelp })), gridCell(12, checkboxField(SETTINGS.debug, c.debug, (v) => {
110
+ c.debug = v;
111
+ app.changed();
112
+ }, { path: 'debug', help: SETTINGS.debugHelp })));
113
+ container.appendChild(disclosure(SHELL.advanced, [fields, resetControl(app)], { cls: 'gn-settings' }));
114
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Validation of the Settings disclosure (SPEC section 11.3 E), mirroring config.schema.json so the host's Save
3
+ * stays disabled while the file would fail its own checks. Each issue carries the field path for inline marking.
4
+ */
5
+ import { SETTINGS, SHELL } from './copy.js';
6
+ import { DEFAULTS } from './model.js';
7
+ export const HHMM_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
8
+ /** True for a finite number with at most one decimal place (12, 12.0 and 11.8; not 12.05). */
9
+ export function hasOneDecimal(value) {
10
+ return Number.isFinite(value) && Math.round(value * 10) / 10 === value;
11
+ }
12
+ export function validate(config) {
13
+ const issues = [];
14
+ if (!config.name.trim()) {
15
+ issues.push({ path: 'name', message: SHELL.required(SETTINGS.name) });
16
+ }
17
+ if (!Number.isFinite(config.pollIdleMinutes)) {
18
+ issues.push({ path: 'pollIdleMinutes', message: SHELL.required(SETTINGS.pollIdle) });
19
+ }
20
+ else if (config.pollIdleMinutes < DEFAULTS.pollIdleMinutesMin) {
21
+ issues.push({ path: 'pollIdleMinutes', message: SETTINGS.pollIdleError });
22
+ }
23
+ if (!Number.isFinite(config.pollActiveSeconds)) {
24
+ issues.push({ path: 'pollActiveSeconds', message: SHELL.required(SETTINGS.pollActive) });
25
+ }
26
+ else if (config.pollActiveSeconds < DEFAULTS.pollActiveSecondsMin) {
27
+ issues.push({ path: 'pollActiveSeconds', message: SETTINGS.pollActiveError });
28
+ }
29
+ if (!Number.isFinite(config.batteryLowVoltage)) {
30
+ issues.push({ path: 'batteryLowVoltage', message: SHELL.required(SETTINGS.batteryLow) });
31
+ }
32
+ else if (!hasOneDecimal(config.batteryLowVoltage)) {
33
+ issues.push({ path: 'batteryLowVoltage', message: SETTINGS.batteryLowError });
34
+ }
35
+ if (config.exerciseTime.trim() && !HHMM_PATTERN.test(config.exerciseTime.trim())) {
36
+ issues.push({ path: 'exerciseTime', message: SETTINGS.exerciseTimeError });
37
+ }
38
+ if (!Number.isFinite(config.exerciseHoldMinutes)) {
39
+ issues.push({ path: 'exerciseHoldMinutes', message: SHELL.required(SETTINGS.exerciseHold) });
40
+ }
41
+ else if (config.exerciseHoldMinutes < DEFAULTS.exerciseHoldMinutesMin) {
42
+ issues.push({ path: 'exerciseHoldMinutes', message: SETTINGS.exerciseHoldError });
43
+ }
44
+ return issues;
45
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Entry point the Homebridge UI starts for this plugin's settings page (SPEC section 10). The implementation
3
+ * is compiled from src/ui/server.ts into dist/ui/server.js and shares the auth, credentials and state modules
4
+ * with the platform, so nothing here duplicates them.
5
+ */
6
+ import { startUiServer } from '../dist/ui/server.js';
7
+
8
+ startUiServer();
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "homebridge-generac",
3
+ "displayName": "Generac",
4
+ "type": "module",
5
+ "version": "0.1.0-beta.1",
6
+ "description": "Generac Mobile Link standby generators in HomeKit: running, fault and maintenance sensors plus starting-battery health.",
7
+ "author": "Alex Rodriguez (https://alex-rodriguez.com)",
8
+ "license": "Apache-2.0",
9
+ "homepage": "https://github.com/arodbuilds/homebridge-generac#readme",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/arodbuilds/homebridge-generac.git"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/arodbuilds/homebridge-generac/issues"
16
+ },
17
+ "keywords": [
18
+ "homebridge-plugin",
19
+ "supports-hap",
20
+ "generac",
21
+ "mobilelink",
22
+ "mobile link",
23
+ "generator",
24
+ "standby generator",
25
+ "homekit"
26
+ ],
27
+ "main": "dist/index.js",
28
+ "bin": {
29
+ "homebridge-generac": "dist/cli.js"
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "homebridge-ui/public",
34
+ "homebridge-ui/server.js",
35
+ "config.schema.json",
36
+ "LICENSE",
37
+ "NOTICE",
38
+ "README.md",
39
+ "CHANGELOG.md",
40
+ "SECURITY.md"
41
+ ],
42
+ "engines": {
43
+ "node": "^20.18.0 || ^22.10.0",
44
+ "homebridge": "^1.8.0 || ^2.0.0-beta.0"
45
+ },
46
+ "scripts": {
47
+ "build": "rimraf ./dist && tsc && npm run build:ui",
48
+ "lint": "eslint . --max-warnings=0",
49
+ "prepublishOnly": "npm run lint && npm run build",
50
+ "test": "npm run lint && npm run build && npm run test:unit",
51
+ "test:unit": "rimraf ./build-test && tsc -p tsconfig.test.json && node --test build-test/test/*.test.js",
52
+ "watch": "npm run build && npm link && nodemon",
53
+ "build:ui": "rimraf ./homebridge-ui/public/js && tsc -p homebridge-ui && node scripts/build-ui.mjs"
54
+ },
55
+ "devDependencies": {
56
+ "@eslint/js": "^10.0.1",
57
+ "@homebridge/hap-nodejs": "^2.2.3",
58
+ "@types/node": "^26.2.0",
59
+ "eslint": "^10.9.0",
60
+ "homebridge": "^2.4.0",
61
+ "nodemon": "^3.1.14",
62
+ "rimraf": "^6.1.3",
63
+ "ts-node": "^10.9.2",
64
+ "typescript": "^6.0.3",
65
+ "typescript-eslint": "^8.67.0"
66
+ },
67
+ "dependencies": {
68
+ "@homebridge/plugin-ui-utils": "^2.2.6"
69
+ }
70
+ }