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.
- package/CHANGELOG.md +32 -0
- package/LICENSE +200 -0
- package/NOTICE +20 -0
- package/README.md +179 -0
- package/SECURITY.md +17 -0
- package/config.schema.json +133 -0
- package/dist/accessory.d.ts +54 -0
- package/dist/accessory.js +191 -0
- package/dist/accessory.js.map +1 -0
- package/dist/api.d.ts +43 -0
- package/dist/api.js +124 -0
- package/dist/api.js.map +1 -0
- package/dist/attention.d.ts +18 -0
- package/dist/attention.js +40 -0
- package/dist/attention.js.map +1 -0
- package/dist/auth.d.ts +56 -0
- package/dist/auth.js +412 -0
- package/dist/auth.js.map +1 -0
- package/dist/captures.d.ts +9 -0
- package/dist/captures.js +42 -0
- package/dist/captures.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +116 -0
- package/dist/cli.js.map +1 -0
- package/dist/exercise.d.ts +42 -0
- package/dist/exercise.js +115 -0
- package/dist/exercise.js.map +1 -0
- package/dist/files.d.ts +5 -0
- package/dist/files.js +28 -0
- package/dist/files.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/model.d.ts +74 -0
- package/dist/model.js +167 -0
- package/dist/model.js.map +1 -0
- package/dist/platform.d.ts +117 -0
- package/dist/platform.js +596 -0
- package/dist/platform.js.map +1 -0
- package/dist/settings.d.ts +75 -0
- package/dist/settings.js +115 -0
- package/dist/settings.js.map +1 -0
- package/dist/state.d.ts +28 -0
- package/dist/state.js +43 -0
- package/dist/state.js.map +1 -0
- package/dist/types.d.ts +98 -0
- package/dist/types.js +48 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/server.d.ts +96 -0
- package/dist/ui/server.js +328 -0
- package/dist/ui/server.js.map +1 -0
- package/homebridge-ui/public/generac-banner.png +0 -0
- package/homebridge-ui/public/generac.css +255 -0
- package/homebridge-ui/public/index.css +510 -0
- package/homebridge-ui/public/index.html +14 -0
- package/homebridge-ui/public/js/api.js +31 -0
- package/homebridge-ui/public/js/app.js +1 -0
- package/homebridge-ui/public/js/card.js +27 -0
- package/homebridge-ui/public/js/copy.js +167 -0
- package/homebridge-ui/public/js/dom.js +247 -0
- package/homebridge-ui/public/js/footer.js +26 -0
- package/homebridge-ui/public/js/format.js +62 -0
- package/homebridge-ui/public/js/main.js +288 -0
- package/homebridge-ui/public/js/mark.js +34 -0
- package/homebridge-ui/public/js/model.js +120 -0
- package/homebridge-ui/public/js/sections/account.js +272 -0
- package/homebridge-ui/public/js/sections/generators.js +165 -0
- package/homebridge-ui/public/js/sections/settings.js +114 -0
- package/homebridge-ui/public/js/validate.js +45 -0
- package/homebridge-ui/server.js +8 -0
- package/package.json +70 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { callServer, setSaveEnabled, toastError } from './api.js';
|
|
2
|
+
import { BANNER, INTRO, SHELL } from './copy.js';
|
|
3
|
+
import { clear, el } from './dom.js';
|
|
4
|
+
import { renderFooter } from './footer.js';
|
|
5
|
+
import { exportConfig, PLATFORM, readConfig } from './model.js';
|
|
6
|
+
import { renderAccount } from './sections/account.js';
|
|
7
|
+
import { renderGenerators } from './sections/generators.js';
|
|
8
|
+
import { renderSettings } from './sections/settings.js';
|
|
9
|
+
import { validate } from './validate.js';
|
|
10
|
+
/**
|
|
11
|
+
* The settings page (SPEC section 11): reads the platform block with `getPluginConfig`, pushes every change with
|
|
12
|
+
* `updatePluginConfig`, leaves saving to the Homebridge UI's Save button (disabled while validation finds errors),
|
|
13
|
+
* and asks the plugin's UI server for /status every 15 seconds while the page is open.
|
|
14
|
+
*/
|
|
15
|
+
export const STATUS_POLL_MS = 15 * 1000;
|
|
16
|
+
const SECTIONS = [
|
|
17
|
+
{ key: 'account', title: INTRO.accountHeading, help: INTRO.accountHelp, render: renderAccount },
|
|
18
|
+
{ key: 'generators', title: INTRO.generatorsHeading, help: INTRO.generatorsHelp, render: renderGenerators },
|
|
19
|
+
{ key: 'settings', title: INTRO.settingsHeading, help: '', render: renderSettings },
|
|
20
|
+
];
|
|
21
|
+
const CONTROLS = ':scope > .form-control, :scope > .input-group > .form-control, :scope > .form-check-input';
|
|
22
|
+
/** True on phones and tablets: the page then gives every button a 44px touch target. */
|
|
23
|
+
export function isTouchDevice() {
|
|
24
|
+
try {
|
|
25
|
+
return (window.matchMedia && window.matchMedia('(pointer: coarse)').matches) || navigator.maxTouchPoints > 0;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export class Page {
|
|
32
|
+
constructor(config, root) {
|
|
33
|
+
this.config = config;
|
|
34
|
+
this.root = root;
|
|
35
|
+
this.status = null;
|
|
36
|
+
this.ui = { flow: null, disconnectOpen: false, resetOpen: false, rename: null, checkingSince: null };
|
|
37
|
+
this.containers = new Map();
|
|
38
|
+
this.touched = new Set();
|
|
39
|
+
/** The messages currently shown inline, by path. A focused field only ever loses its message, never gains one. */
|
|
40
|
+
this.shown = new Map();
|
|
41
|
+
this.otherBlocks = [];
|
|
42
|
+
this.exerciseTimeSettled = config.exerciseTime.trim().length > 0;
|
|
43
|
+
root.appendChild(el('img', {
|
|
44
|
+
class: 'ns-banner', src: BANNER.file, alt: `${BANNER.title}: ${BANNER.tagline}`, width: '1280', height: '320',
|
|
45
|
+
}));
|
|
46
|
+
root.appendChild(el('p', { class: 'lead-copy' }, INTRO.one));
|
|
47
|
+
root.appendChild(el('p', { class: 'lead-copy' }, INTRO.two));
|
|
48
|
+
root.appendChild(el('p', { class: 'form-text gn-affiliation' }, INTRO.affiliation));
|
|
49
|
+
for (const section of SECTIONS) {
|
|
50
|
+
const container = el('div', { class: 'section-body' });
|
|
51
|
+
this.containers.set(section.key, container);
|
|
52
|
+
root.appendChild(el('section', { class: 'ns-section', id: `section-${section.key}` }, el('h2', { class: 'h5' }, section.title), section.help ? el('p', { class: 'section-copy' }, section.help) : null, container));
|
|
53
|
+
}
|
|
54
|
+
root.appendChild(el('p', { class: 'lead-copy mt-3' }, INTRO.closing));
|
|
55
|
+
this.footer = renderFooter();
|
|
56
|
+
root.appendChild(this.footer.el);
|
|
57
|
+
// Validation on blur: leaving a control touches its field; typing alone does not. Typing does clear a message
|
|
58
|
+
// the moment the field is fixed, so nothing under the field moves when it is left (a button below it would
|
|
59
|
+
// otherwise shift between mousedown and mouseup).
|
|
60
|
+
root.addEventListener('focusout', (event) => {
|
|
61
|
+
if (event.target instanceof HTMLElement && event.target.matches('input, select, textarea')) {
|
|
62
|
+
const path = event.target.closest('[data-path]')?.dataset.path;
|
|
63
|
+
if (path) {
|
|
64
|
+
if (path === 'exerciseTime') {
|
|
65
|
+
this.exerciseTimeSettled = true;
|
|
66
|
+
}
|
|
67
|
+
this.touch(path);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
root.addEventListener('input', (event) => {
|
|
72
|
+
if (event.target instanceof HTMLElement && event.target.matches('input, select, textarea')) {
|
|
73
|
+
this.markIssues(validate(this.config));
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
setOtherBlocks(blocks) {
|
|
78
|
+
this.otherBlocks = blocks;
|
|
79
|
+
}
|
|
80
|
+
renderAll() {
|
|
81
|
+
for (const section of SECTIONS) {
|
|
82
|
+
this.rerender(section.key);
|
|
83
|
+
}
|
|
84
|
+
this.revalidate();
|
|
85
|
+
}
|
|
86
|
+
rerender(section) {
|
|
87
|
+
const container = this.containers.get(section);
|
|
88
|
+
if (!container) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const open = [...container.querySelectorAll('details')].map((d) => d.open);
|
|
92
|
+
clear(container);
|
|
93
|
+
SECTIONS.find((s) => s.key === section)?.render(this, container);
|
|
94
|
+
[...container.querySelectorAll('details')].forEach((d, i) => {
|
|
95
|
+
d.open = open[i] ?? d.open;
|
|
96
|
+
});
|
|
97
|
+
this.markIssues(validate(this.config));
|
|
98
|
+
}
|
|
99
|
+
changed() {
|
|
100
|
+
this.revalidate();
|
|
101
|
+
this.push();
|
|
102
|
+
}
|
|
103
|
+
replaceConfig(config) {
|
|
104
|
+
this.config = config;
|
|
105
|
+
this.touched.clear();
|
|
106
|
+
this.exerciseTimeSettled = true;
|
|
107
|
+
this.renderAll();
|
|
108
|
+
this.push();
|
|
109
|
+
}
|
|
110
|
+
touch(path) {
|
|
111
|
+
this.touched.add(path);
|
|
112
|
+
this.markIssues(validate(this.config));
|
|
113
|
+
}
|
|
114
|
+
untouch(path) {
|
|
115
|
+
this.touched.delete(path);
|
|
116
|
+
}
|
|
117
|
+
issues() {
|
|
118
|
+
return validate(this.config);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Asks the server for /status and redraws the account card from the answer (unless the Connect flow is in
|
|
122
|
+
* progress) and the generator cards (unless a Rename editor is open).
|
|
123
|
+
*/
|
|
124
|
+
async refreshStatus() {
|
|
125
|
+
var _a;
|
|
126
|
+
const status = await callServer('/status');
|
|
127
|
+
if (!status || !status.account) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
this.status = status;
|
|
131
|
+
this.footer.setVersion(status.version);
|
|
132
|
+
this.prefillExerciseTime();
|
|
133
|
+
if (status.account.state === 'checking') {
|
|
134
|
+
(_a = this.ui).checkingSince ?? (_a.checkingSince = Date.now());
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
this.ui.checkingSince = null;
|
|
138
|
+
}
|
|
139
|
+
if (!this.ui.flow) {
|
|
140
|
+
this.rerender('account');
|
|
141
|
+
}
|
|
142
|
+
if (!this.ui.rename) {
|
|
143
|
+
this.rerender('generators');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** The Exercise time setting is prefilled from Mobile Link once (SPEC section 11.3 E) and pushed, so Save keeps it. */
|
|
147
|
+
prefillExerciseTime() {
|
|
148
|
+
if (this.exerciseTimeSettled || !this.status) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const fromApi = this.status.generators.map((g) => g.exerciseTimeFromApi).find((t) => typeof t === 'string' && t.length > 0);
|
|
152
|
+
if (!fromApi) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
this.exerciseTimeSettled = true;
|
|
156
|
+
this.config.exerciseTime = fromApi;
|
|
157
|
+
const settings = this.containers.get('settings');
|
|
158
|
+
if (settings && !settings.contains(document.activeElement)) {
|
|
159
|
+
this.rerender('settings');
|
|
160
|
+
}
|
|
161
|
+
this.changed();
|
|
162
|
+
}
|
|
163
|
+
startPolling() {
|
|
164
|
+
void this.refreshStatus();
|
|
165
|
+
this.pollTimer = window.setInterval(() => void this.refreshStatus(), STATUS_POLL_MS);
|
|
166
|
+
window.addEventListener('pagehide', () => {
|
|
167
|
+
if (this.pollTimer !== undefined) {
|
|
168
|
+
window.clearInterval(this.pollTimer);
|
|
169
|
+
this.pollTimer = undefined;
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
revalidate() {
|
|
174
|
+
const issues = validate(this.config);
|
|
175
|
+
this.markIssues(issues);
|
|
176
|
+
setSaveEnabled(issues.length === 0);
|
|
177
|
+
}
|
|
178
|
+
push() {
|
|
179
|
+
if (this.pushTimer !== undefined) {
|
|
180
|
+
window.clearTimeout(this.pushTimer);
|
|
181
|
+
}
|
|
182
|
+
this.pushTimer = window.setTimeout(() => {
|
|
183
|
+
this.pushTimer = undefined;
|
|
184
|
+
window.homebridge.updatePluginConfig([exportConfig(this.config), ...this.otherBlocks]).catch((err) => {
|
|
185
|
+
toastError(`${SHELL.updateFailed} ${err instanceof Error ? err.message : String(err)}`);
|
|
186
|
+
});
|
|
187
|
+
}, 150);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Draws the inline state of every field: the issue on a touched field as a message under it, nothing otherwise.
|
|
191
|
+
* The field that has focus never gains a message; one it already shows is kept until the issue is gone.
|
|
192
|
+
*/
|
|
193
|
+
markIssues(issues) {
|
|
194
|
+
const active = document.activeElement instanceof HTMLElement ? document.activeElement.closest('[data-path]') : null;
|
|
195
|
+
const activePath = active?.dataset.path;
|
|
196
|
+
const byPath = new Map();
|
|
197
|
+
for (const issue of issues) {
|
|
198
|
+
if (!byPath.has(issue.path)) {
|
|
199
|
+
byPath.set(issue.path, issue);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const shown = new Map();
|
|
203
|
+
for (const node of this.root.querySelectorAll('[data-path]')) {
|
|
204
|
+
const path = node.dataset.path ?? '';
|
|
205
|
+
if (path.startsWith('connect.') || path.startsWith('generators[')) {
|
|
206
|
+
// The Connect flow and the Rename editor validate their own required fields.
|
|
207
|
+
this.markRequired(node, path, path === activePath);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const issue = byPath.get(path);
|
|
211
|
+
let message = issue && this.touched.has(path) ? issue.message : undefined;
|
|
212
|
+
if (path === activePath && message !== undefined) {
|
|
213
|
+
message = this.shown.get(path);
|
|
214
|
+
}
|
|
215
|
+
if (message !== undefined) {
|
|
216
|
+
shown.set(path, message);
|
|
217
|
+
}
|
|
218
|
+
const feedback = node.querySelector(':scope > .invalid-feedback');
|
|
219
|
+
node.classList.toggle('has-issue', message !== undefined);
|
|
220
|
+
if (feedback) {
|
|
221
|
+
feedback.textContent = message ?? '';
|
|
222
|
+
}
|
|
223
|
+
for (const control of node.querySelectorAll(CONTROLS)) {
|
|
224
|
+
control.classList.toggle('is-invalid', message !== undefined);
|
|
225
|
+
}
|
|
226
|
+
if (message !== undefined) {
|
|
227
|
+
// An issue on a field under the collapsed disclosure would otherwise be invisible.
|
|
228
|
+
for (let details = node.closest('details'); details; details = details.parentElement?.closest('details') ?? null) {
|
|
229
|
+
details.open = true;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
this.shown = shown;
|
|
234
|
+
}
|
|
235
|
+
/** "{Label} is required." for the Connect fields once touched (SPEC section 11.2); a focused field only loses it. */
|
|
236
|
+
markRequired(node, path, focused) {
|
|
237
|
+
if (!path.startsWith('connect.') || !this.touched.has(path)) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const control = node.querySelector(CONTROLS);
|
|
241
|
+
const label = node.querySelector('label')?.firstChild?.textContent?.trim() ?? '';
|
|
242
|
+
const missing = !!control && control.value.trim() === '' && (!focused || node.classList.contains('has-issue'));
|
|
243
|
+
node.classList.toggle('has-issue', missing);
|
|
244
|
+
control?.classList.toggle('is-invalid', missing);
|
|
245
|
+
const feedback = node.querySelector(':scope > .invalid-feedback');
|
|
246
|
+
if (feedback) {
|
|
247
|
+
feedback.textContent = missing ? `${label} is required.` : '';
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
async function start() {
|
|
252
|
+
const root = document.getElementById('app');
|
|
253
|
+
if (!root) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (isTouchDevice()) {
|
|
257
|
+
document.body.classList.add('ns-touch');
|
|
258
|
+
}
|
|
259
|
+
const hb = window.homebridge;
|
|
260
|
+
hb.showSpinner();
|
|
261
|
+
try {
|
|
262
|
+
const blocks = await hb.getPluginConfig();
|
|
263
|
+
const index = blocks.findIndex((block) => block && typeof block === 'object' && block.platform === PLATFORM);
|
|
264
|
+
const raw = index >= 0 ? blocks[index] : undefined;
|
|
265
|
+
const page = new Page(readConfig(raw), root);
|
|
266
|
+
page.setOtherBlocks(blocks.filter((_, i) => i !== index));
|
|
267
|
+
page.renderAll();
|
|
268
|
+
if (index < 0) {
|
|
269
|
+
// A fresh install: the block exists once Save is clicked.
|
|
270
|
+
page.changed();
|
|
271
|
+
}
|
|
272
|
+
page.startPolling();
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
root.appendChild(el('div', { class: 'alert alert-danger' }, `${SHELL.loadFailed} ${err instanceof Error ? err.message : String(err)}`));
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
hb.hideSpinner();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (document.readyState === 'loading') {
|
|
282
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
283
|
+
start().catch(() => undefined);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
start().catch(() => undefined);
|
|
288
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { el } from './dom.js';
|
|
2
|
+
/**
|
|
3
|
+
* The Generac for Homebridge mark (assets/generac-mark.svg without its metadata), drawn inline so the footer
|
|
4
|
+
* loads no file. It uses `currentColor`, so it takes the surrounding text colour and follows the host's theme.
|
|
5
|
+
*/
|
|
6
|
+
const PATHS = [
|
|
7
|
+
['rect', { x: '30', y: '34', width: '132', height: '90', rx: '12' }],
|
|
8
|
+
['path', { d: 'M56 62H94M56 79H94M56 96H94', 'stroke-linecap': 'round' }],
|
|
9
|
+
['rect', { x: '112', y: '64', width: '30', height: '30', rx: '6' }],
|
|
10
|
+
['path', { d: 'M66 124V148M126 124V148', 'stroke-linecap': 'round' }],
|
|
11
|
+
['path', { d: 'M18 154H174', 'stroke-linecap': 'round' }],
|
|
12
|
+
];
|
|
13
|
+
/** The mark at `size` CSS pixels, decorative (`aria-hidden`), in the surrounding text colour. */
|
|
14
|
+
export function renderMark(size) {
|
|
15
|
+
const ns = 'http://www.w3.org/2000/svg';
|
|
16
|
+
const svg = document.createElementNS(ns, 'svg');
|
|
17
|
+
svg.setAttribute('viewBox', '0 0 192 192');
|
|
18
|
+
svg.setAttribute('width', String(size));
|
|
19
|
+
svg.setAttribute('height', String(size));
|
|
20
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
21
|
+
svg.setAttribute('focusable', 'false');
|
|
22
|
+
svg.classList.add('ns-mark-svg');
|
|
23
|
+
for (const [tag, attrs] of PATHS) {
|
|
24
|
+
const node = document.createElementNS(ns, tag);
|
|
25
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
26
|
+
node.setAttribute(k, v);
|
|
27
|
+
}
|
|
28
|
+
node.setAttribute('fill', 'none');
|
|
29
|
+
node.setAttribute('stroke', 'currentColor');
|
|
30
|
+
node.setAttribute('stroke-width', '12');
|
|
31
|
+
svg.appendChild(node);
|
|
32
|
+
}
|
|
33
|
+
return el('span', { class: 'ns-mark' }, svg).firstElementChild;
|
|
34
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The platform block as the page edits it (SPEC section 9). `readConfig` accepts whatever config.json holds and
|
|
3
|
+
* fills in the defaults; `exportConfig` writes the block back, keeping keys the page does not know (the host's
|
|
4
|
+
* `_bridge` settings, `credentialsPath`) exactly as loaded.
|
|
5
|
+
*/
|
|
6
|
+
export const PLATFORM = 'Generac';
|
|
7
|
+
export const DEFAULTS = {
|
|
8
|
+
name: 'Generac',
|
|
9
|
+
pollIdleMinutes: 10,
|
|
10
|
+
pollIdleMinutesMin: 2,
|
|
11
|
+
pollActiveSeconds: 90,
|
|
12
|
+
pollActiveSecondsMin: 60,
|
|
13
|
+
batteryLowVoltage: 12.0,
|
|
14
|
+
faultOnStopped: true,
|
|
15
|
+
faultOnDisconnected: false,
|
|
16
|
+
attentionSensor: false,
|
|
17
|
+
exerciseSensor: true,
|
|
18
|
+
exerciseHoldMinutes: 5,
|
|
19
|
+
exerciseHoldMinutesMin: 1,
|
|
20
|
+
debug: false,
|
|
21
|
+
};
|
|
22
|
+
const KNOWN = new Set([
|
|
23
|
+
'platform', 'name', 'pollIdleMinutes', 'pollActiveSeconds', 'batteryLowVoltage', 'faultOnStopped', 'faultOnDisconnected',
|
|
24
|
+
'attentionSensor', 'exerciseSensor', 'exerciseTime', 'exerciseHoldMinutes', 'debug', 'generators',
|
|
25
|
+
]);
|
|
26
|
+
function num(value, fallback) {
|
|
27
|
+
const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : Number.NaN;
|
|
28
|
+
return Number.isFinite(n) ? n : fallback;
|
|
29
|
+
}
|
|
30
|
+
function bool(value, fallback) {
|
|
31
|
+
return typeof value === 'boolean' ? value : fallback;
|
|
32
|
+
}
|
|
33
|
+
export function emptyConfig() {
|
|
34
|
+
return {
|
|
35
|
+
name: DEFAULTS.name,
|
|
36
|
+
pollIdleMinutes: DEFAULTS.pollIdleMinutes,
|
|
37
|
+
pollActiveSeconds: DEFAULTS.pollActiveSeconds,
|
|
38
|
+
batteryLowVoltage: DEFAULTS.batteryLowVoltage,
|
|
39
|
+
faultOnStopped: DEFAULTS.faultOnStopped,
|
|
40
|
+
faultOnDisconnected: DEFAULTS.faultOnDisconnected,
|
|
41
|
+
attentionSensor: DEFAULTS.attentionSensor,
|
|
42
|
+
exerciseSensor: DEFAULTS.exerciseSensor,
|
|
43
|
+
exerciseTime: '',
|
|
44
|
+
exerciseHoldMinutes: DEFAULTS.exerciseHoldMinutes,
|
|
45
|
+
debug: DEFAULTS.debug,
|
|
46
|
+
generators: [],
|
|
47
|
+
extra: {},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export function readConfig(raw) {
|
|
51
|
+
const c = emptyConfig();
|
|
52
|
+
if (!raw || typeof raw !== 'object') {
|
|
53
|
+
return c;
|
|
54
|
+
}
|
|
55
|
+
const r = raw;
|
|
56
|
+
c.name = typeof r.name === 'string' && r.name.trim() ? r.name : DEFAULTS.name;
|
|
57
|
+
c.pollIdleMinutes = num(r.pollIdleMinutes, DEFAULTS.pollIdleMinutes);
|
|
58
|
+
c.pollActiveSeconds = num(r.pollActiveSeconds, DEFAULTS.pollActiveSeconds);
|
|
59
|
+
c.batteryLowVoltage = num(r.batteryLowVoltage, DEFAULTS.batteryLowVoltage);
|
|
60
|
+
c.faultOnStopped = bool(r.faultOnStopped, DEFAULTS.faultOnStopped);
|
|
61
|
+
c.faultOnDisconnected = bool(r.faultOnDisconnected, DEFAULTS.faultOnDisconnected);
|
|
62
|
+
c.attentionSensor = bool(r.attentionSensor, DEFAULTS.attentionSensor);
|
|
63
|
+
c.exerciseSensor = bool(r.exerciseSensor, DEFAULTS.exerciseSensor);
|
|
64
|
+
c.exerciseTime = typeof r.exerciseTime === 'string' ? r.exerciseTime.trim() : '';
|
|
65
|
+
c.exerciseHoldMinutes = num(r.exerciseHoldMinutes, DEFAULTS.exerciseHoldMinutes);
|
|
66
|
+
c.debug = bool(r.debug, DEFAULTS.debug);
|
|
67
|
+
if (Array.isArray(r.generators)) {
|
|
68
|
+
for (const g of r.generators) {
|
|
69
|
+
if (g && typeof g === 'object') {
|
|
70
|
+
const id = num(g.apparatusId, Number.NaN);
|
|
71
|
+
const name = g.name;
|
|
72
|
+
if (Number.isFinite(id) && typeof name === 'string' && name.trim()) {
|
|
73
|
+
c.generators.push({ apparatusId: id, name: name.trim() });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
for (const [key, value] of Object.entries(r)) {
|
|
79
|
+
if (!KNOWN.has(key)) {
|
|
80
|
+
c.extra[key] = value;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return c;
|
|
84
|
+
}
|
|
85
|
+
/** The block for `updatePluginConfig`. Optional keys at their default are written all the same, so the file reads as the page shows. */
|
|
86
|
+
export function exportConfig(c) {
|
|
87
|
+
const block = {
|
|
88
|
+
...c.extra,
|
|
89
|
+
platform: PLATFORM,
|
|
90
|
+
name: c.name,
|
|
91
|
+
pollIdleMinutes: c.pollIdleMinutes,
|
|
92
|
+
pollActiveSeconds: c.pollActiveSeconds,
|
|
93
|
+
batteryLowVoltage: c.batteryLowVoltage,
|
|
94
|
+
faultOnStopped: c.faultOnStopped,
|
|
95
|
+
faultOnDisconnected: c.faultOnDisconnected,
|
|
96
|
+
attentionSensor: c.attentionSensor,
|
|
97
|
+
exerciseSensor: c.exerciseSensor,
|
|
98
|
+
exerciseHoldMinutes: c.exerciseHoldMinutes,
|
|
99
|
+
debug: c.debug,
|
|
100
|
+
};
|
|
101
|
+
if (c.exerciseTime.trim()) {
|
|
102
|
+
block.exerciseTime = c.exerciseTime.trim();
|
|
103
|
+
}
|
|
104
|
+
if (c.generators.length > 0) {
|
|
105
|
+
block.generators = c.generators.map((g) => ({ apparatusId: g.apparatusId, name: g.name }));
|
|
106
|
+
}
|
|
107
|
+
return block;
|
|
108
|
+
}
|
|
109
|
+
/** The display name override for a generator, or undefined. */
|
|
110
|
+
export function overrideFor(c, apparatusId) {
|
|
111
|
+
return c.generators.find((g) => g.apparatusId === apparatusId)?.name;
|
|
112
|
+
}
|
|
113
|
+
/** Sets or clears a generator's display name override: a name equal to the Mobile Link name needs no override. */
|
|
114
|
+
export function setOverride(c, apparatusId, name, mobileLinkName) {
|
|
115
|
+
const trimmed = name.trim();
|
|
116
|
+
c.generators = c.generators.filter((g) => g.apparatusId !== apparatusId);
|
|
117
|
+
if (trimmed && trimmed !== mobileLinkName) {
|
|
118
|
+
c.generators.push({ apparatusId, name: trimmed });
|
|
119
|
+
}
|
|
120
|
+
}
|