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,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every string the settings page shows, verbatim from SPEC section 11.3 (the harness checks each constant
|
|
3
|
+
* against SPEC.md). Functions build the strings with a value inserted; their templates are in the SPEC too.
|
|
4
|
+
*/
|
|
5
|
+
export const TOAST_TITLE = 'Generac for Homebridge';
|
|
6
|
+
/** SPEC 11.3 A. The banner artwork carries the title and tagline; here they are its alt text. */
|
|
7
|
+
export const BANNER = {
|
|
8
|
+
file: 'generac-banner.png',
|
|
9
|
+
title: 'Generac for Homebridge',
|
|
10
|
+
tagline: 'Standby generator status in the Home app',
|
|
11
|
+
};
|
|
12
|
+
export const INTRO = {
|
|
13
|
+
one: 'Generac for Homebridge shows the standby generators on your Mobile Link account in the Home app. '
|
|
14
|
+
+ 'Each generator appears as a Running sensor, a Fault sensor, a Maintenance Due sensor and a starting battery reading. '
|
|
15
|
+
+ 'Propane models will also get a tank level later.',
|
|
16
|
+
two: 'It is read only. It cannot start, stop or exercise the generator. Connect your account below and your generators are found for you.',
|
|
17
|
+
accountHeading: 'Mobile Link account',
|
|
18
|
+
accountHelp: 'One account per Homebridge instance. Use the email and password you use in the Mobile Link app.',
|
|
19
|
+
generatorsHeading: 'Generators',
|
|
20
|
+
generatorsHelp: 'Every generator on the account gets a card here. You can rename each one.',
|
|
21
|
+
generatorsEmpty: 'No generators yet. Connect your Mobile Link account above and they appear here within a minute.',
|
|
22
|
+
settingsHeading: 'Settings',
|
|
23
|
+
closing: 'Your generators appear in the Home app as sensors. Add them to automations; for example, turn on a light when Running opens.',
|
|
24
|
+
affiliation: 'Not affiliated with or endorsed by Generac Power Systems, Inc. Generac and Mobile Link are its trademarks. '
|
|
25
|
+
+ 'Uses Generac\'s undocumented Mobile Link API, which can change without notice.',
|
|
26
|
+
};
|
|
27
|
+
/** SPEC 11.3 B. */
|
|
28
|
+
export const ACCOUNT = {
|
|
29
|
+
title: 'Mobile Link account',
|
|
30
|
+
notConnectedBody: 'Sign in once with your Mobile Link email and password. Your password is used to sign in and is not stored.',
|
|
31
|
+
connect: 'Connect',
|
|
32
|
+
checking: 'Checking',
|
|
33
|
+
checkingBody: 'Signed in. Homebridge picks up the new sign-in within a minute.',
|
|
34
|
+
checkingSlow: 'Homebridge hasn\'t picked it up yet. Restart Homebridge from Power Options.',
|
|
35
|
+
connected: 'Connected',
|
|
36
|
+
lastChecked: (relative) => `Last checked ${relative}`,
|
|
37
|
+
disconnect: 'Disconnect',
|
|
38
|
+
disconnectQuestion: 'Disconnect this account? The plugin will stop updating your generators until you connect again.',
|
|
39
|
+
keep: 'Keep',
|
|
40
|
+
reconnectBadge: 'Reconnect needed',
|
|
41
|
+
reconnectBody: 'Mobile Link signed this plugin out. This happens after a password change or if Generac revokes access. Sign in again to resume updates.',
|
|
42
|
+
reconnectAttention: 'The Attention needed sensor is on in HomeKit until you reconnect.',
|
|
43
|
+
reconnect: 'Reconnect',
|
|
44
|
+
};
|
|
45
|
+
/** SPEC 11.3 C. */
|
|
46
|
+
export const CONNECT = {
|
|
47
|
+
email: 'Email',
|
|
48
|
+
emailPlaceholder: 'e.g. you@example.com',
|
|
49
|
+
password: 'Password',
|
|
50
|
+
passwordHelp: 'Used once to sign in. Not stored.',
|
|
51
|
+
signIn: 'Sign in',
|
|
52
|
+
cancel: 'Cancel',
|
|
53
|
+
signingIn: 'Signing in…',
|
|
54
|
+
wrongPassword: 'Mobile Link rejected that password. If you haven\'t reset it since Generac\'s security update on April 21, 2026, '
|
|
55
|
+
+ 'reset it in the Mobile Link app, then try again.',
|
|
56
|
+
unknownEmail: 'Mobile Link doesn\'t recognize that email.',
|
|
57
|
+
network: 'Couldn\'t reach Mobile Link. Try again in a minute.',
|
|
58
|
+
codeTitle: 'Enter your code',
|
|
59
|
+
codeBodySms: 'Mobile Link texted a code to the phone on your account.',
|
|
60
|
+
codeBodyOtp: 'Enter the code from your authenticator app.',
|
|
61
|
+
codeBodyEmail: (email) => `Mobile Link emailed a code to ${email}.`,
|
|
62
|
+
codeSecondLine: 'Didn\'t get it? Cancel and sign in again.',
|
|
63
|
+
code: 'Code',
|
|
64
|
+
codePlaceholder: 'e.g. 123456',
|
|
65
|
+
continue: 'Continue',
|
|
66
|
+
wrongCode: 'That code didn\'t work. Check for a newer message and try again.',
|
|
67
|
+
tooMany: 'Too many attempts. Cancel and sign in again.',
|
|
68
|
+
expired: 'That sign-in timed out. Cancel and sign in again.',
|
|
69
|
+
unsupported: 'Your account uses a sign-in method this plugin can\'t complete. In the Mobile Link app, switch to text message '
|
|
70
|
+
+ 'or an authenticator app, then try again.',
|
|
71
|
+
};
|
|
72
|
+
/** SPEC 11.3 D. */
|
|
73
|
+
export const GENERATOR = {
|
|
74
|
+
maintenanceDue: 'Maintenance due',
|
|
75
|
+
serial: (serial) => `S/N ${serial}`,
|
|
76
|
+
ready: 'Ready to run',
|
|
77
|
+
running: 'Running',
|
|
78
|
+
exercising: 'Exercising',
|
|
79
|
+
fault: 'Fault',
|
|
80
|
+
notResponding: 'Not responding',
|
|
81
|
+
notRespondingNote: (time) => `Mobile Link hasn't heard from this generator since ${time}.`,
|
|
82
|
+
battery: 'Battery',
|
|
83
|
+
volts: (volts) => `${volts} V`,
|
|
84
|
+
low: 'Low',
|
|
85
|
+
fuel: 'Fuel',
|
|
86
|
+
percent: (percent) => `${percent}%`,
|
|
87
|
+
fuelHelp: 'Propane models only',
|
|
88
|
+
engineHours: 'Engine hours',
|
|
89
|
+
hours: (n) => `${n} h`,
|
|
90
|
+
exerciseTime: 'Exercise time',
|
|
91
|
+
weekly: (time) => `${time} weekly`,
|
|
92
|
+
lastExercise: 'Last exercise',
|
|
93
|
+
dateAt: (date, time) => `${date} at ${time}`,
|
|
94
|
+
lastSeen: 'Last seen',
|
|
95
|
+
rename: 'Rename',
|
|
96
|
+
name: 'Name',
|
|
97
|
+
saveName: 'Save name',
|
|
98
|
+
cancel: 'Cancel',
|
|
99
|
+
alsoPropane: 'Also on your account: Propane tank monitor (tank level support is coming).',
|
|
100
|
+
alsoEcobee: (name) => `Also on your account: ecobee thermostat "${name}" (already in HomeKit, skipped).`,
|
|
101
|
+
};
|
|
102
|
+
/** SPEC 11.3 E. */
|
|
103
|
+
export const SETTINGS = {
|
|
104
|
+
name: 'Name',
|
|
105
|
+
pollIdle: 'Poll interval while idle (minutes)',
|
|
106
|
+
pollIdleHelp: 'Mobile Link updates every few minutes and limits how often you can check. Faster than this rarely helps.',
|
|
107
|
+
pollIdleError: 'Minimum is 2 minutes.',
|
|
108
|
+
pollActive: 'Poll interval while running or in fault (seconds)',
|
|
109
|
+
pollActiveHelp: 'Used while a generator is running, exercising, or reporting a problem.',
|
|
110
|
+
pollActiveError: 'Minimum is 60 seconds.',
|
|
111
|
+
batteryLow: 'Low battery threshold (volts)',
|
|
112
|
+
batteryLowHelp: 'A healthy starting battery on charge reads 13.4 to 13.8 V.',
|
|
113
|
+
batteryLowError: 'Enter volts with one decimal place, for example 12.0.',
|
|
114
|
+
faultOnStopped: 'Treat Stopped as a fault',
|
|
115
|
+
faultOnStoppedHelp: 'Stopped means the control switch is in OFF and the generator won\'t start during an outage.',
|
|
116
|
+
faultOnDisconnected: 'Treat a lost connection as a fault',
|
|
117
|
+
faultOnDisconnectedHelp: 'Off by default. Wi-Fi drops are common and the sensors already show Not responding.',
|
|
118
|
+
attentionSensor: 'Attention needed sensor',
|
|
119
|
+
attentionSensorHelp: 'Adds an occupancy sensor to HomeKit that turns on when the plugin needs you to reconnect.',
|
|
120
|
+
exerciseSensor: 'Exercising sensor',
|
|
121
|
+
exerciseSensorHelp: 'Adds a sensor that opens while the weekly exercise runs, or when Mobile Link reports one finished. '
|
|
122
|
+
+ 'Use it to confirm the generator exercised this week.',
|
|
123
|
+
exerciseTime: 'Exercise time',
|
|
124
|
+
exerciseTimeHelp: 'When your generator\'s weekly exercise starts. Prefilled from Mobile Link; correct it if your unit starts at a different time. '
|
|
125
|
+
+ 'The plugin checks more often around this time every day.',
|
|
126
|
+
exerciseTimePlaceholder: 'e.g. 10:00',
|
|
127
|
+
exerciseTimeError: 'Enter a time as HH:MM, for example 10:00.',
|
|
128
|
+
exerciseHold: 'Exercise hold (minutes)',
|
|
129
|
+
exerciseHoldHelp: 'How long the Exercising sensor stays open after an exercise is detected.',
|
|
130
|
+
exerciseHoldError: 'Minimum is 1 minute.',
|
|
131
|
+
debug: 'Debug logging',
|
|
132
|
+
debugHelp: 'Verbose logging. Your password is never logged, even with this on.',
|
|
133
|
+
resetLines: [
|
|
134
|
+
'Signs out of Mobile Link and removes the saved sign-in.',
|
|
135
|
+
'Removes every generator and its sensors from the Home app.',
|
|
136
|
+
'Clears all settings on this page.',
|
|
137
|
+
],
|
|
138
|
+
};
|
|
139
|
+
/** Shell strings (SPEC 11.3 F): the disclosure, the Reset dialog, the password toggle, the credit footer and relative times. */
|
|
140
|
+
export const SHELL = {
|
|
141
|
+
advanced: 'Advanced',
|
|
142
|
+
reset: 'Reset plugin to fresh install',
|
|
143
|
+
resetTitle: 'Reset plugin to fresh install?',
|
|
144
|
+
resetPrompt: 'Type RESET to confirm.',
|
|
145
|
+
resetConfirm: 'Confirm',
|
|
146
|
+
resetCancel: 'Cancel',
|
|
147
|
+
resetDone: 'Signed out and reset. Click Save, then restart Homebridge.',
|
|
148
|
+
show: 'Show',
|
|
149
|
+
hide: 'Hide',
|
|
150
|
+
required: (label) => `${label} is required.`,
|
|
151
|
+
loadFailed: 'Could not load the configuration.',
|
|
152
|
+
updateFailed: 'Could not update the configuration.',
|
|
153
|
+
};
|
|
154
|
+
export const FOOTER = {
|
|
155
|
+
name: 'Generac for Homebridge',
|
|
156
|
+
madeBy: 'Made by Alex Rodriguez',
|
|
157
|
+
site: 'alex-rodriguez.com',
|
|
158
|
+
siteUrl: 'https://alex-rodriguez.com/?ref=generac',
|
|
159
|
+
issues: 'Report an issue',
|
|
160
|
+
issuesUrl: 'https://github.com/arodbuilds/homebridge-generac/issues',
|
|
161
|
+
};
|
|
162
|
+
export const RELATIVE = {
|
|
163
|
+
justNow: 'just now',
|
|
164
|
+
minutes: (n) => (n === 1 ? '1 minute ago' : `${n} minutes ago`),
|
|
165
|
+
hours: (n) => (n === 1 ? '1 hour ago' : `${n} hours ago`),
|
|
166
|
+
days: (n) => (n === 1 ? '1 day ago' : `${n} days ago`),
|
|
167
|
+
};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny DOM helpers on the shell's field and button anatomy (homebridge-notify-switch homebridge-ui/src/dom.ts, the
|
|
3
|
+
* pieces this page needs). The UI is plain HTML built with these; Bootstrap 5 classes come from the Homebridge
|
|
4
|
+
* UI, which injects its stylesheet and theme into the settings iframe.
|
|
5
|
+
*/
|
|
6
|
+
import { SHELL } from './copy.js';
|
|
7
|
+
export function append(parent, ...children) {
|
|
8
|
+
for (const child of children) {
|
|
9
|
+
if (child === null || child === undefined || child === false) {
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
parent.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function el(tag, attrs = {}, ...children) {
|
|
16
|
+
const node = document.createElement(tag);
|
|
17
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
18
|
+
if (value === undefined || value === false) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (key === 'class') {
|
|
22
|
+
node.className = String(value);
|
|
23
|
+
}
|
|
24
|
+
else if (value === true) {
|
|
25
|
+
node.setAttribute(key, '');
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
node.setAttribute(key, value);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
append(node, ...children);
|
|
32
|
+
return node;
|
|
33
|
+
}
|
|
34
|
+
export function clear(node) {
|
|
35
|
+
while (node.firstChild) {
|
|
36
|
+
node.removeChild(node.firstChild);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
let idCounter = 0;
|
|
40
|
+
export function uniqueId(prefix = 'f') {
|
|
41
|
+
idCounter += 1;
|
|
42
|
+
return `${prefix}-${idCounter}`;
|
|
43
|
+
}
|
|
44
|
+
/** One line of field help. Carries `ns-help` like the shell so a help toggle could collapse it. */
|
|
45
|
+
export function helpText(text, extra = '') {
|
|
46
|
+
return el('div', { class: `form-text ns-help${extra ? ` ${extra}` : ''}` }, text);
|
|
47
|
+
}
|
|
48
|
+
function wrapField(id, label, control, opts, invalidTarget) {
|
|
49
|
+
const star = opts.required ? el('span', { class: 'text-danger ms-1', 'aria-hidden': 'true' }, '*') : null;
|
|
50
|
+
return el('div', { class: 'mb-3', 'data-path': opts.path, 'data-invalid-target': invalidTarget ? 'group' : undefined }, el('label', { class: 'form-label', for: id }, label, star), control, opts.help ? helpText(opts.help) : null, el('div', { class: 'invalid-feedback' }));
|
|
51
|
+
}
|
|
52
|
+
/** A labelled input with optional help text, calling `onChange` with the new string on every input event. */
|
|
53
|
+
export function textField(label, value, onChange, opts = {}) {
|
|
54
|
+
const id = uniqueId();
|
|
55
|
+
const input = el('input', {
|
|
56
|
+
id,
|
|
57
|
+
class: `form-control${opts.monospace ? ' font-monospace' : ''}`,
|
|
58
|
+
type: opts.type ?? 'text',
|
|
59
|
+
value,
|
|
60
|
+
placeholder: opts.placeholder,
|
|
61
|
+
autocomplete: opts.autocomplete ?? 'off',
|
|
62
|
+
inputmode: opts.inputmode,
|
|
63
|
+
spellcheck: 'false',
|
|
64
|
+
});
|
|
65
|
+
input.addEventListener('input', () => onChange(input.value));
|
|
66
|
+
return wrapField(id, label, input, opts);
|
|
67
|
+
}
|
|
68
|
+
/** A number input; `onChange` receives the parsed number or NaN. With `format`, the value is shown formatted on load and after blur. */
|
|
69
|
+
export function numberField(label, value, onChange, opts = {}) {
|
|
70
|
+
const id = uniqueId();
|
|
71
|
+
const shown = (v) => (Number.isFinite(v) ? opts.format?.(v) ?? String(v) : '');
|
|
72
|
+
const input = el('input', {
|
|
73
|
+
id, class: 'form-control', type: 'number', value: shown(value), inputmode: opts.step ? 'decimal' : 'numeric',
|
|
74
|
+
min: opts.min !== undefined ? String(opts.min) : undefined, max: opts.max !== undefined ? String(opts.max) : undefined, step: opts.step ?? '1',
|
|
75
|
+
});
|
|
76
|
+
const parse = () => (input.value.trim() === '' ? Number.NaN : Number(input.value));
|
|
77
|
+
input.addEventListener('input', () => onChange(parse()));
|
|
78
|
+
if (opts.format) {
|
|
79
|
+
input.addEventListener('blur', () => {
|
|
80
|
+
const formatted = shown(parse());
|
|
81
|
+
if (formatted && formatted !== input.value) {
|
|
82
|
+
input.value = formatted;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return wrapField(id, label, input, opts);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A password input with a Show/Hide toggle. The value is never echoed anywhere but the input itself.
|
|
90
|
+
* `autocomplete` is `new-password` because browsers ignore `off` on password inputs and would offer the
|
|
91
|
+
* saved Homebridge login. The toggle only flips `type`; the value is untouched.
|
|
92
|
+
*/
|
|
93
|
+
export function passwordField(label, value, onChange, opts = {}) {
|
|
94
|
+
const id = uniqueId();
|
|
95
|
+
const input = el('input', { id, class: 'form-control font-monospace', type: 'password', value, autocomplete: 'new-password', spellcheck: 'false' });
|
|
96
|
+
input.addEventListener('input', () => onChange(input.value));
|
|
97
|
+
const toggle = el('button', { class: 'btn btn-outline-secondary', type: 'button', 'aria-label': `${SHELL.show} ${label}` }, SHELL.show);
|
|
98
|
+
toggle.addEventListener('click', () => {
|
|
99
|
+
const reveal = input.type === 'password';
|
|
100
|
+
input.type = reveal ? 'text' : 'password';
|
|
101
|
+
toggle.textContent = reveal ? SHELL.hide : SHELL.show;
|
|
102
|
+
toggle.setAttribute('aria-label', `${reveal ? SHELL.hide : SHELL.show} ${label}`);
|
|
103
|
+
});
|
|
104
|
+
const group = el('div', { class: 'input-group' }, input, toggle);
|
|
105
|
+
return wrapField(id, label, group, opts, input);
|
|
106
|
+
}
|
|
107
|
+
export function checkboxField(label, checked, onChange, opts = {}) {
|
|
108
|
+
const id = uniqueId();
|
|
109
|
+
const input = el('input', { id, class: 'form-check-input', type: 'checkbox' });
|
|
110
|
+
input.checked = checked;
|
|
111
|
+
input.addEventListener('change', () => onChange(input.checked));
|
|
112
|
+
return el('div', { class: 'form-check mb-3', 'data-path': opts.path }, input, el('label', { class: 'form-check-label', for: id }, label), opts.help ? helpText(opts.help) : null, el('div', { class: 'invalid-feedback' }));
|
|
113
|
+
}
|
|
114
|
+
export function button(label, onClick, cls = 'btn btn-outline-primary btn-sm') {
|
|
115
|
+
const node = el('button', { type: 'button', class: cls }, label);
|
|
116
|
+
node.addEventListener('click', onClick);
|
|
117
|
+
return node;
|
|
118
|
+
}
|
|
119
|
+
export function paragraph(text, cls = 'section-copy') {
|
|
120
|
+
return el('p', { class: cls }, text);
|
|
121
|
+
}
|
|
122
|
+
/** The one 38px primary button of a section (Connect, Reconnect, Sign in, Continue). */
|
|
123
|
+
export function primaryButton(label, onClick) {
|
|
124
|
+
return button(label, onClick, 'btn btn-primary ns-section-add');
|
|
125
|
+
}
|
|
126
|
+
/** A link-style (text) button: no border or background, for Cancel, Keep and Disconnect. */
|
|
127
|
+
export function linkButton(label, onClick, extra = '') {
|
|
128
|
+
return button(label, onClick, `btn btn-link btn-sm p-0 ns-link-button${extra ? ` ${extra}` : ''}`);
|
|
129
|
+
}
|
|
130
|
+
/** A red text button: Reset only. */
|
|
131
|
+
export function dangerLinkButton(label, onClick) {
|
|
132
|
+
return linkButton(label, onClick, 'text-danger ns-danger-link');
|
|
133
|
+
}
|
|
134
|
+
/** An outlined secondary 31px button (the Unsupported factor Cancel). */
|
|
135
|
+
export function outlineButton(label, onClick, extra = '') {
|
|
136
|
+
return button(label, onClick, `btn btn-outline-secondary btn-sm${extra ? ` ${extra}` : ''}`);
|
|
137
|
+
}
|
|
138
|
+
/** The card footer's primary action (Rename): an outlined, link-coloured 31px button. */
|
|
139
|
+
export function footerAction(label, onClick) {
|
|
140
|
+
return button(label, onClick, 'btn btn-outline-primary btn-sm ns-footer-action');
|
|
141
|
+
}
|
|
142
|
+
/** A cell of the 12-column grid: `span` columns wide, full width below 600px. */
|
|
143
|
+
export function gridCell(span, ...children) {
|
|
144
|
+
return el('div', { class: `ns-span-${span}` }, ...children);
|
|
145
|
+
}
|
|
146
|
+
/** The 12-column grid with its 8px column gap; `cells` come from `gridCell`. */
|
|
147
|
+
export function grid(...cells) {
|
|
148
|
+
return el('div', { class: 'ns-grid' }, ...cells);
|
|
149
|
+
}
|
|
150
|
+
/** A collapsed disclosure ("Advanced"). */
|
|
151
|
+
export function disclosure(summary, body, opts = {}) {
|
|
152
|
+
const details = el('details', { class: `ns-advanced${opts.cls ? ` ${opts.cls}` : ''}` }, el('summary', { class: 'ns-secondary small' }, summary), el('div', { class: 'mt-2' }, ...body));
|
|
153
|
+
if (opts.open) {
|
|
154
|
+
details.open = true;
|
|
155
|
+
}
|
|
156
|
+
return details;
|
|
157
|
+
}
|
|
158
|
+
/** A toned status box (a Connect error). */
|
|
159
|
+
export function statusBox(kind, message) {
|
|
160
|
+
return el('div', { class: `status-box alert alert-${kind} py-2 px-3 mb-3`, role: 'status' }, message);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Brings a control that just opened into view. The page sits in an iframe the host sizes to the content, so a
|
|
164
|
+
* `scrollIntoView` here scrolls the host's modal (same-origin). The host learns the new height a moment after the
|
|
165
|
+
* page grows (its ResizeObserver posts it to the parent), so the call is made now and once more after that.
|
|
166
|
+
*/
|
|
167
|
+
export function reveal(node) {
|
|
168
|
+
const scroll = () => {
|
|
169
|
+
try {
|
|
170
|
+
node.scrollIntoView({ block: 'center' });
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// An old browser without the options form: leave the page where it is.
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
scroll();
|
|
177
|
+
window.setTimeout(scroll, 250);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* In-place confirmation: clicking `start` replaces it with the question, a confirm button and a text Cancel
|
|
181
|
+
* button. Escape or Cancel restores the original button.
|
|
182
|
+
*/
|
|
183
|
+
export function inlineConfirm(opts) {
|
|
184
|
+
const control = el('span', { class: `d-inline-flex flex-wrap align-items-center gap-2 ns-inline-confirm${opts.cls ? ` ${opts.cls}` : ''}` });
|
|
185
|
+
let onKey = () => undefined;
|
|
186
|
+
const reset = () => {
|
|
187
|
+
document.removeEventListener('keydown', onKey);
|
|
188
|
+
clear(control);
|
|
189
|
+
control.appendChild(opts.start);
|
|
190
|
+
opts.onOpen?.(false);
|
|
191
|
+
};
|
|
192
|
+
onKey = (event) => {
|
|
193
|
+
if (event.key === 'Escape') {
|
|
194
|
+
event.preventDefault();
|
|
195
|
+
reset();
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
const show = (clicked) => {
|
|
199
|
+
clear(control);
|
|
200
|
+
control.appendChild(el('span', { class: 'small ns-confirm-question' }, opts.question));
|
|
201
|
+
const confirm = button(opts.confirmLabel, () => {
|
|
202
|
+
reset();
|
|
203
|
+
opts.onConfirm();
|
|
204
|
+
}, opts.confirmClass);
|
|
205
|
+
control.appendChild(confirm);
|
|
206
|
+
control.appendChild(linkButton(opts.cancelLabel, reset));
|
|
207
|
+
document.addEventListener('keydown', onKey);
|
|
208
|
+
if (clicked) {
|
|
209
|
+
opts.onOpen?.(true);
|
|
210
|
+
confirm.focus();
|
|
211
|
+
reveal(control);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
opts.start.addEventListener('click', () => show(true));
|
|
215
|
+
if (opts.open) {
|
|
216
|
+
show(false);
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
control.appendChild(opts.start);
|
|
220
|
+
}
|
|
221
|
+
return control;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* A dialog in the page flow (the Reset dialog), drawn where it is opened rather than over the page: a fixed
|
|
225
|
+
* overlay would pin to the iframe's own top, off-screen when the opener sits at the bottom of a scrolled host
|
|
226
|
+
* modal. Escape closes it; `onClose` runs on every close, including the caller's.
|
|
227
|
+
*/
|
|
228
|
+
export function inlineDialog(opts) {
|
|
229
|
+
const dialog = el('div', { class: 'ns-inline-dialog', role: 'dialog', 'aria-label': opts.title });
|
|
230
|
+
let onKey = () => undefined;
|
|
231
|
+
const close = () => {
|
|
232
|
+
document.removeEventListener('keydown', onKey);
|
|
233
|
+
dialog.remove();
|
|
234
|
+
opts.onClose?.();
|
|
235
|
+
};
|
|
236
|
+
onKey = (event) => {
|
|
237
|
+
if (event.key === 'Escape') {
|
|
238
|
+
event.preventDefault();
|
|
239
|
+
close();
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
document.addEventListener('keydown', onKey);
|
|
243
|
+
dialog.appendChild(el('div', { class: 'ns-inline-dialog-title fw-semibold' }, opts.title));
|
|
244
|
+
dialog.appendChild(el('div', { class: 'ns-inline-dialog-body' }, opts.body));
|
|
245
|
+
dialog.appendChild(el('div', { class: 'ns-inline-dialog-actions' }, ...opts.actions));
|
|
246
|
+
return { el: dialog, close };
|
|
247
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { FOOTER } from './copy.js';
|
|
2
|
+
import { el } from './dom.js';
|
|
3
|
+
import { renderMark } from './mark.js';
|
|
4
|
+
/**
|
|
5
|
+
* Version and credit footer (SPEC section 11.1, item 6): the last element on the page, one line of secondary
|
|
6
|
+
* text. The version arrives with /status. Both links open in a new tab; the site link
|
|
7
|
+
* carries `?ref=generac` and nothing else is tracked.
|
|
8
|
+
*/
|
|
9
|
+
const MARK_SIZE = 20;
|
|
10
|
+
function outLink(text, href) {
|
|
11
|
+
return el('a', { href, target: '_blank', rel: 'noopener noreferrer' }, text);
|
|
12
|
+
}
|
|
13
|
+
function item(...children) {
|
|
14
|
+
return el('span', { class: 'ns-footer-item' }, ...children);
|
|
15
|
+
}
|
|
16
|
+
export function renderFooter() {
|
|
17
|
+
const label = document.createTextNode(FOOTER.name);
|
|
18
|
+
const separator = () => ' · ';
|
|
19
|
+
const footer = el('footer', { class: 'ns-footer form-text' }, item(el('span', { class: 'ns-mark-label' }, renderMark(MARK_SIZE), label)), separator(), item(FOOTER.madeBy), separator(), item(outLink(FOOTER.site, FOOTER.siteUrl)), separator(), item(outLink(FOOTER.issues, FOOTER.issuesUrl)));
|
|
20
|
+
return {
|
|
21
|
+
el: footer,
|
|
22
|
+
setVersion(version) {
|
|
23
|
+
label.textContent = version ? `${FOOTER.name} v${version}` : FOOTER.name;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Times as the page shows them, in the browser's locale and time zone: 12-hour times, US-style dates (SPEC
|
|
3
|
+
* writing style) and the relative times of the account and generator cards.
|
|
4
|
+
*/
|
|
5
|
+
import { RELATIVE } from './copy.js';
|
|
6
|
+
export function parseDate(value) {
|
|
7
|
+
if (!value) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
const d = new Date(value);
|
|
11
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
12
|
+
}
|
|
13
|
+
/** "3:15 PM". */
|
|
14
|
+
export function formatTime(d) {
|
|
15
|
+
return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
|
16
|
+
}
|
|
17
|
+
/** "September 12, 2026". */
|
|
18
|
+
export function formatDate(d) {
|
|
19
|
+
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
|
20
|
+
}
|
|
21
|
+
/** "3:15 PM" today, else "September 12, 2026 at 3:15 PM". */
|
|
22
|
+
export function formatTimeOrDate(d, now = new Date()) {
|
|
23
|
+
const sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
|
|
24
|
+
return sameDay ? formatTime(d) : `${formatDate(d)} at ${formatTime(d)}`;
|
|
25
|
+
}
|
|
26
|
+
/** "just now", "2 minutes ago", "3 hours ago", "2 days ago". */
|
|
27
|
+
export function relativeTime(d, now = new Date()) {
|
|
28
|
+
const seconds = Math.max(0, Math.round((now.getTime() - d.getTime()) / 1000));
|
|
29
|
+
if (seconds < 45) {
|
|
30
|
+
return RELATIVE.justNow;
|
|
31
|
+
}
|
|
32
|
+
const minutes = Math.round(seconds / 60);
|
|
33
|
+
if (minutes < 60) {
|
|
34
|
+
return RELATIVE.minutes(Math.max(1, minutes));
|
|
35
|
+
}
|
|
36
|
+
const hours = Math.round(minutes / 60);
|
|
37
|
+
if (hours < 24) {
|
|
38
|
+
return RELATIVE.hours(hours);
|
|
39
|
+
}
|
|
40
|
+
return RELATIVE.days(Math.round(hours / 24));
|
|
41
|
+
}
|
|
42
|
+
/** "10:00" to "10:00 AM"; null when the value is not HH:MM. */
|
|
43
|
+
export function hhmmTo12h(value) {
|
|
44
|
+
if (!value) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const m = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
|
|
48
|
+
if (!m) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const h = Number(m[1]);
|
|
52
|
+
const min = Number(m[2]);
|
|
53
|
+
if (h > 23 || min > 59) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const h12 = h % 12 === 0 ? 12 : h % 12;
|
|
57
|
+
return `${h12}:${String(min).padStart(2, '0')} ${h < 12 ? 'AM' : 'PM'}`;
|
|
58
|
+
}
|
|
59
|
+
/** A number with at most one decimal, for volts. */
|
|
60
|
+
export function formatVolts(v) {
|
|
61
|
+
return (Math.round(v * 10) / 10).toFixed(1);
|
|
62
|
+
}
|