lightman-agent 1.0.4 → 1.0.6
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/agent.config.template.json +30 -30
- package/package.json +52 -52
- package/public/assets/index-CcBNCz6h.css +1 -1
- package/public/assets/index-D9QHMG8k.js +1 -0
- package/public/assets/index-H-8HDl46.js +1 -1
- package/public/assets/index-YodeiCia.css +1 -0
- package/public/assets/index-legacy-DWtNM8y7.js +41 -0
- package/public/assets/museum-map-CwVDA2z1.svg +4182 -0
- package/public/assets/polyfills-legacy-DyVYWHbW.js +4 -0
- package/public/index.html +7 -2
- package/public/templates/custom08/elements/back-button.svg +20 -0
- package/public/templates/custom08/elements/base-map-background.svg +37 -0
- package/public/templates/custom08/elements/base-map.svg +1191 -0
- package/public/templates/custom08/elements/gallery-1-2-3-info-panel.svg +236 -0
- package/public/templates/custom08/elements/gallery-4-5-6-7-info-panel.svg +266 -0
- package/public/templates/custom08/elements/gallery-8-9-info-panel.svg +274 -0
- package/public/templates/custom08/elements/gallery-labels/_nav-map-styles.css +554 -0
- package/public/templates/custom08/elements/gallery-labels/_styles.css +556 -0
- package/public/templates/custom08/elements/gallery-labels/gallery-1.svg +35 -0
- package/public/templates/custom08/elements/gallery-labels/gallery-2.svg +34 -0
- package/public/templates/custom08/elements/gallery-labels/gallery-3.svg +34 -0
- package/public/templates/custom08/elements/gallery-labels/gallery-4.svg +37 -0
- package/public/templates/custom08/elements/gallery-labels/gallery-5.svg +34 -0
- package/public/templates/custom08/elements/gallery-labels/gallery-6.svg +34 -0
- package/public/templates/custom08/elements/gallery-labels/gallery-7.svg +34 -0
- package/public/templates/custom08/elements/gallery-labels/gallery-8.svg +37 -0
- package/public/templates/custom08/elements/gallery-labels/gallery-9.svg +34 -0
- package/public/templates/custom08/elements/hand-hint.png +0 -0
- package/public/templates/custom08/elements/idle-screen-bg.svg +5 -0
- package/public/templates/custom08/elements/idle-screen-map.svg +627 -0
- package/public/templates/custom08/elements/idle-screen-text.svg +350 -0
- package/public/templates/custom08/elements/key-map-1.svg +986 -0
- package/public/templates/custom08/elements/key-map-2.svg +1018 -0
- package/public/templates/custom08/elements/key-map-3.svg +1019 -0
- package/public/templates/custom08/elements/key-map-combined.svg +1001 -0
- package/public/templates/custom08/elements/map-highlight-marker.svg +11 -0
- package/public/templates/custom08/elements/map-pin-marker.svg +15 -0
- package/public/templates/custom08/elements/map-teardrop-star-marker.svg +13 -0
- package/public/templates/custom08/elements/nav-circle-galleries-1-3.svg +21 -0
- package/public/templates/custom08/elements/nav-circle-galleries-4-7.svg +24 -0
- package/public/templates/custom08/elements/nav-circle-galleries-8-9.svg +20 -0
- package/public/templates/custom08/elements/section1-map.svg +1435 -0
- package/public/templates/custom08/elements/section2-map.svg +1724 -0
- package/public/templates/custom08/elements/section3-map.svg +1295 -0
- package/public/templates/custom08/fonts/CabinetGrotesk-Variable.ttf +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-05_at_7.23.12_PM.png +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-05_at_7.23.56_PM.png +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-05_at_7.24.24_PM.png +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.31.58_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.11_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.36_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.48_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.59_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.15_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.27_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.34_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.42_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.50_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.58_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.04_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.11_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.20_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.57_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.35.03_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.35.16_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.35.23_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/prologue-highlight.png +0 -0
- package/scripts/guardian.ps1 +75 -75
- package/scripts/install-linux.sh +134 -134
- package/scripts/install-rpi.sh +117 -117
- package/scripts/install-windows.ps1 +513 -512
- package/scripts/launch-kiosk.vbs +101 -101
- package/scripts/lightman-agent.logrotate +12 -12
- package/scripts/lightman-agent.service +38 -38
- package/scripts/lightman-shell.bat +107 -107
- package/scripts/reinstall-windows.ps1 +26 -26
- package/scripts/restore-desktop.ps1 +32 -32
- package/scripts/setup.ps1 +116 -116
- package/scripts/setup.sh +115 -115
- package/scripts/sync-display.mjs +20 -0
- package/scripts/uninstall-linux.sh +50 -50
- package/scripts/uninstall-windows.ps1 +54 -54
- package/src/commands/display.ts +177 -177
- package/src/commands/kiosk.ts +113 -113
- package/src/commands/maintenance.ts +106 -106
- package/src/commands/network.ts +129 -129
- package/src/commands/power.ts +163 -163
- package/src/commands/rpi.ts +45 -45
- package/src/commands/screenshot.ts +166 -166
- package/src/commands/serial.ts +17 -17
- package/src/commands/update.ts +124 -124
- package/src/index.ts +652 -652
- package/src/lib/config.ts +69 -69
- package/src/lib/identity.ts +40 -40
- package/src/lib/logger.ts +137 -137
- package/src/lib/platform.ts +10 -10
- package/src/lib/rpi.ts +180 -180
- package/src/lib/screens.ts +128 -128
- package/src/lib/types.ts +176 -176
- package/src/services/commands.ts +107 -107
- package/src/services/health.ts +161 -161
- package/src/services/kiosk.ts +384 -384
- package/src/services/localEvents.ts +60 -60
- package/src/services/logForwarder.ts +72 -72
- package/src/services/multiScreenKiosk.ts +324 -324
- package/src/services/oscBridge.ts +186 -186
- package/src/services/powerScheduler.ts +260 -260
- package/src/services/provisioning.ts +120 -120
- package/src/services/serialBridge.ts +230 -230
- package/src/services/serviceLauncher.ts +183 -183
- package/src/services/staticServer.ts +226 -226
- package/src/services/updater.ts +249 -249
- package/src/services/watchdog.ts +310 -310
- package/src/services/websocket.ts +152 -152
- package/tsconfig.json +28 -28
|
@@ -1,260 +1,260 @@
|
|
|
1
|
-
import { execFile } from 'child_process';
|
|
2
|
-
import { getPlatform } from '../lib/platform.js';
|
|
3
|
-
import type { PowerScheduleConfig } from '../lib/types.js';
|
|
4
|
-
import type { Logger } from '../lib/logger.js';
|
|
5
|
-
import type { WsClient } from './websocket.js';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* PowerScheduler — handles local cron-based shutdown and server-pushed power commands.
|
|
9
|
-
*
|
|
10
|
-
* Shutdown flow:
|
|
11
|
-
* 1. Every minute, check if current time matches shutdownCron
|
|
12
|
-
* 2. If match: send warning to server, wait shutdownWarningSeconds, then shut down
|
|
13
|
-
* 3. Also listens for server-pushed "system:shutdown" via the command executor (separate)
|
|
14
|
-
*
|
|
15
|
-
* The server can also override/update the schedule at runtime via WebSocket.
|
|
16
|
-
*/
|
|
17
|
-
export class PowerScheduler {
|
|
18
|
-
private config: PowerScheduleConfig;
|
|
19
|
-
private logger: Logger;
|
|
20
|
-
private wsClient: WsClient;
|
|
21
|
-
private timer: NodeJS.Timeout | null = null;
|
|
22
|
-
private shutdownPending = false;
|
|
23
|
-
private shutdownTimer: NodeJS.Timeout | null = null;
|
|
24
|
-
|
|
25
|
-
constructor(config: PowerScheduleConfig, logger: Logger, wsClient: WsClient) {
|
|
26
|
-
this.config = config;
|
|
27
|
-
this.logger = logger;
|
|
28
|
-
this.wsClient = wsClient;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
start(): void {
|
|
32
|
-
if (!this.config.shutdownCron) {
|
|
33
|
-
this.logger.info('PowerScheduler: no shutdownCron configured, skipping');
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
this.logger.info(
|
|
38
|
-
`PowerScheduler started (shutdown: "${this.config.shutdownCron}", tz: ${this.config.timezone})`
|
|
39
|
-
);
|
|
40
|
-
|
|
41
|
-
// Check every 30 seconds (cron resolution is 1 minute, but we check more often to not miss)
|
|
42
|
-
this.timer = setInterval(() => {
|
|
43
|
-
this.checkSchedule();
|
|
44
|
-
}, 30_000);
|
|
45
|
-
|
|
46
|
-
// Also check immediately
|
|
47
|
-
this.checkSchedule();
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
stop(): void {
|
|
51
|
-
if (this.timer) {
|
|
52
|
-
clearInterval(this.timer);
|
|
53
|
-
this.timer = null;
|
|
54
|
-
}
|
|
55
|
-
if (this.shutdownTimer) {
|
|
56
|
-
clearTimeout(this.shutdownTimer);
|
|
57
|
-
this.shutdownTimer = null;
|
|
58
|
-
}
|
|
59
|
-
this.shutdownPending = false;
|
|
60
|
-
this.logger.info('PowerScheduler stopped');
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Update the schedule at runtime (e.g., from server push).
|
|
65
|
-
*/
|
|
66
|
-
updateSchedule(newConfig: Partial<PowerScheduleConfig>): void {
|
|
67
|
-
if (newConfig.shutdownCron !== undefined) {
|
|
68
|
-
this.config.shutdownCron = newConfig.shutdownCron;
|
|
69
|
-
}
|
|
70
|
-
if (newConfig.startupCron !== undefined) {
|
|
71
|
-
this.config.startupCron = newConfig.startupCron;
|
|
72
|
-
}
|
|
73
|
-
if (newConfig.timezone !== undefined) {
|
|
74
|
-
this.config.timezone = newConfig.timezone;
|
|
75
|
-
}
|
|
76
|
-
if (newConfig.shutdownWarningSeconds !== undefined) {
|
|
77
|
-
this.config.shutdownWarningSeconds = newConfig.shutdownWarningSeconds;
|
|
78
|
-
}
|
|
79
|
-
this.logger.info(`PowerScheduler schedule updated: shutdown="${this.config.shutdownCron}"`);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Trigger shutdown immediately (called by server command or local schedule).
|
|
84
|
-
*/
|
|
85
|
-
triggerShutdown(reason: string): void {
|
|
86
|
-
if (this.shutdownPending) {
|
|
87
|
-
this.logger.info('Shutdown already pending, ignoring duplicate trigger');
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
this.shutdownPending = true;
|
|
92
|
-
const warningSeconds = this.config.shutdownWarningSeconds ?? 60;
|
|
93
|
-
|
|
94
|
-
this.logger.warn(`Shutdown triggered (${reason}), warning period: ${warningSeconds}s`);
|
|
95
|
-
|
|
96
|
-
// Notify server that shutdown is imminent
|
|
97
|
-
this.wsClient.send({
|
|
98
|
-
type: 'agent:shutdown-warning',
|
|
99
|
-
payload: {
|
|
100
|
-
reason,
|
|
101
|
-
shutdownInSeconds: warningSeconds,
|
|
102
|
-
},
|
|
103
|
-
timestamp: Date.now(),
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
this.shutdownTimer = setTimeout(() => {
|
|
107
|
-
this.executeShutdown(reason);
|
|
108
|
-
}, warningSeconds * 1_000);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Cancel a pending scheduled shutdown.
|
|
113
|
-
*/
|
|
114
|
-
cancelShutdown(): boolean {
|
|
115
|
-
if (!this.shutdownPending || !this.shutdownTimer) {
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
clearTimeout(this.shutdownTimer);
|
|
119
|
-
this.shutdownTimer = null;
|
|
120
|
-
this.shutdownPending = false;
|
|
121
|
-
|
|
122
|
-
this.logger.info('Scheduled shutdown cancelled');
|
|
123
|
-
this.wsClient.send({
|
|
124
|
-
type: 'agent:shutdown-cancelled',
|
|
125
|
-
payload: {},
|
|
126
|
-
timestamp: Date.now(),
|
|
127
|
-
});
|
|
128
|
-
return true;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
isShutdownPending(): boolean {
|
|
132
|
-
return this.shutdownPending;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// --- Private ---
|
|
136
|
-
|
|
137
|
-
private checkSchedule(): void {
|
|
138
|
-
if (!this.config.shutdownCron || this.shutdownPending) return;
|
|
139
|
-
|
|
140
|
-
const now = this.getNowInTimezone();
|
|
141
|
-
if (this.matchesCron(this.config.shutdownCron, now)) {
|
|
142
|
-
this.triggerShutdown('local-schedule');
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
private executeShutdown(reason: string): void {
|
|
147
|
-
this.logger.warn(`Executing system shutdown (reason: ${reason})`);
|
|
148
|
-
|
|
149
|
-
// Notify server before going down
|
|
150
|
-
this.wsClient.send({
|
|
151
|
-
type: 'agent:shutting-down',
|
|
152
|
-
payload: { reason },
|
|
153
|
-
timestamp: Date.now(),
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
const platform = getPlatform();
|
|
157
|
-
let bin: string;
|
|
158
|
-
let args: string[];
|
|
159
|
-
|
|
160
|
-
switch (platform) {
|
|
161
|
-
case 'windows':
|
|
162
|
-
bin = 'shutdown';
|
|
163
|
-
args = ['/s', '/t', '5', '/c', `LIGHTMAN scheduled shutdown: ${reason}`];
|
|
164
|
-
break;
|
|
165
|
-
case 'darwin':
|
|
166
|
-
bin = 'shutdown';
|
|
167
|
-
args = ['-h', '+1'];
|
|
168
|
-
break;
|
|
169
|
-
default:
|
|
170
|
-
bin = 'shutdown';
|
|
171
|
-
args = ['-h', 'now'];
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Give WS message time to send, then shut down
|
|
175
|
-
setTimeout(() => {
|
|
176
|
-
execFile(bin, args, (err) => {
|
|
177
|
-
if (err) {
|
|
178
|
-
this.logger.error('Shutdown exec failed:', err.message);
|
|
179
|
-
this.shutdownPending = false;
|
|
180
|
-
}
|
|
181
|
-
});
|
|
182
|
-
}, 2_000);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Get current date/time components in the configured timezone.
|
|
187
|
-
*/
|
|
188
|
-
private getNowInTimezone(): { minute: number; hour: number; dayOfMonth: number; month: number; dayOfWeek: number } {
|
|
189
|
-
const tz = this.config.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
190
|
-
const now = new Date();
|
|
191
|
-
|
|
192
|
-
// Use Intl to get components in the target timezone
|
|
193
|
-
const parts = new Intl.DateTimeFormat('en-US', {
|
|
194
|
-
timeZone: tz,
|
|
195
|
-
hour: 'numeric',
|
|
196
|
-
minute: 'numeric',
|
|
197
|
-
day: 'numeric',
|
|
198
|
-
month: 'numeric',
|
|
199
|
-
weekday: 'short',
|
|
200
|
-
hour12: false,
|
|
201
|
-
}).formatToParts(now);
|
|
202
|
-
|
|
203
|
-
const get = (type: string) => {
|
|
204
|
-
const part = parts.find((p) => p.type === type);
|
|
205
|
-
return part ? parseInt(part.value, 10) : 0;
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
const weekdayStr = parts.find((p) => p.type === 'weekday')?.value || '';
|
|
209
|
-
const dayOfWeekMap: Record<string, number> = {
|
|
210
|
-
Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6,
|
|
211
|
-
};
|
|
212
|
-
|
|
213
|
-
return {
|
|
214
|
-
minute: get('minute'),
|
|
215
|
-
hour: get('hour'),
|
|
216
|
-
dayOfMonth: get('day'),
|
|
217
|
-
month: get('month'),
|
|
218
|
-
dayOfWeek: dayOfWeekMap[weekdayStr] ?? 0,
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Simple 5-field cron matcher: minute hour dayOfMonth month dayOfWeek
|
|
224
|
-
* Supports: *, numbers, ranges (1-5), steps (asterisk/5), lists (1,3,5)
|
|
225
|
-
*/
|
|
226
|
-
private matchesCron(
|
|
227
|
-
expr: string,
|
|
228
|
-
now: { minute: number; hour: number; dayOfMonth: number; month: number; dayOfWeek: number }
|
|
229
|
-
): boolean {
|
|
230
|
-
const fields = expr.trim().split(/\s+/);
|
|
231
|
-
if (fields.length !== 5) return false;
|
|
232
|
-
|
|
233
|
-
const values = [now.minute, now.hour, now.dayOfMonth, now.month, now.dayOfWeek];
|
|
234
|
-
|
|
235
|
-
return fields.every((field, i) => this.matchesCronField(field, values[i]));
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
private matchesCronField(field: string, value: number): boolean {
|
|
239
|
-
// Handle list: "1,3,5"
|
|
240
|
-
const parts = field.split(',');
|
|
241
|
-
return parts.some((part) => {
|
|
242
|
-
// Handle step: "*/5" or "1-10/2"
|
|
243
|
-
const [rangeOrWild, stepStr] = part.split('/');
|
|
244
|
-
const step = stepStr ? parseInt(stepStr, 10) : 1;
|
|
245
|
-
|
|
246
|
-
if (rangeOrWild === '*') {
|
|
247
|
-
return value % step === 0;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// Handle range: "1-5"
|
|
251
|
-
if (rangeOrWild.includes('-')) {
|
|
252
|
-
const [lo, hi] = rangeOrWild.split('-').map(Number);
|
|
253
|
-
return value >= lo && value <= hi && (value - lo) % step === 0;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// Single value
|
|
257
|
-
return parseInt(rangeOrWild, 10) === value;
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
}
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
|
+
import { getPlatform } from '../lib/platform.js';
|
|
3
|
+
import type { PowerScheduleConfig } from '../lib/types.js';
|
|
4
|
+
import type { Logger } from '../lib/logger.js';
|
|
5
|
+
import type { WsClient } from './websocket.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* PowerScheduler — handles local cron-based shutdown and server-pushed power commands.
|
|
9
|
+
*
|
|
10
|
+
* Shutdown flow:
|
|
11
|
+
* 1. Every minute, check if current time matches shutdownCron
|
|
12
|
+
* 2. If match: send warning to server, wait shutdownWarningSeconds, then shut down
|
|
13
|
+
* 3. Also listens for server-pushed "system:shutdown" via the command executor (separate)
|
|
14
|
+
*
|
|
15
|
+
* The server can also override/update the schedule at runtime via WebSocket.
|
|
16
|
+
*/
|
|
17
|
+
export class PowerScheduler {
|
|
18
|
+
private config: PowerScheduleConfig;
|
|
19
|
+
private logger: Logger;
|
|
20
|
+
private wsClient: WsClient;
|
|
21
|
+
private timer: NodeJS.Timeout | null = null;
|
|
22
|
+
private shutdownPending = false;
|
|
23
|
+
private shutdownTimer: NodeJS.Timeout | null = null;
|
|
24
|
+
|
|
25
|
+
constructor(config: PowerScheduleConfig, logger: Logger, wsClient: WsClient) {
|
|
26
|
+
this.config = config;
|
|
27
|
+
this.logger = logger;
|
|
28
|
+
this.wsClient = wsClient;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
start(): void {
|
|
32
|
+
if (!this.config.shutdownCron) {
|
|
33
|
+
this.logger.info('PowerScheduler: no shutdownCron configured, skipping');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.logger.info(
|
|
38
|
+
`PowerScheduler started (shutdown: "${this.config.shutdownCron}", tz: ${this.config.timezone})`
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// Check every 30 seconds (cron resolution is 1 minute, but we check more often to not miss)
|
|
42
|
+
this.timer = setInterval(() => {
|
|
43
|
+
this.checkSchedule();
|
|
44
|
+
}, 30_000);
|
|
45
|
+
|
|
46
|
+
// Also check immediately
|
|
47
|
+
this.checkSchedule();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
stop(): void {
|
|
51
|
+
if (this.timer) {
|
|
52
|
+
clearInterval(this.timer);
|
|
53
|
+
this.timer = null;
|
|
54
|
+
}
|
|
55
|
+
if (this.shutdownTimer) {
|
|
56
|
+
clearTimeout(this.shutdownTimer);
|
|
57
|
+
this.shutdownTimer = null;
|
|
58
|
+
}
|
|
59
|
+
this.shutdownPending = false;
|
|
60
|
+
this.logger.info('PowerScheduler stopped');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Update the schedule at runtime (e.g., from server push).
|
|
65
|
+
*/
|
|
66
|
+
updateSchedule(newConfig: Partial<PowerScheduleConfig>): void {
|
|
67
|
+
if (newConfig.shutdownCron !== undefined) {
|
|
68
|
+
this.config.shutdownCron = newConfig.shutdownCron;
|
|
69
|
+
}
|
|
70
|
+
if (newConfig.startupCron !== undefined) {
|
|
71
|
+
this.config.startupCron = newConfig.startupCron;
|
|
72
|
+
}
|
|
73
|
+
if (newConfig.timezone !== undefined) {
|
|
74
|
+
this.config.timezone = newConfig.timezone;
|
|
75
|
+
}
|
|
76
|
+
if (newConfig.shutdownWarningSeconds !== undefined) {
|
|
77
|
+
this.config.shutdownWarningSeconds = newConfig.shutdownWarningSeconds;
|
|
78
|
+
}
|
|
79
|
+
this.logger.info(`PowerScheduler schedule updated: shutdown="${this.config.shutdownCron}"`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Trigger shutdown immediately (called by server command or local schedule).
|
|
84
|
+
*/
|
|
85
|
+
triggerShutdown(reason: string): void {
|
|
86
|
+
if (this.shutdownPending) {
|
|
87
|
+
this.logger.info('Shutdown already pending, ignoring duplicate trigger');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
this.shutdownPending = true;
|
|
92
|
+
const warningSeconds = this.config.shutdownWarningSeconds ?? 60;
|
|
93
|
+
|
|
94
|
+
this.logger.warn(`Shutdown triggered (${reason}), warning period: ${warningSeconds}s`);
|
|
95
|
+
|
|
96
|
+
// Notify server that shutdown is imminent
|
|
97
|
+
this.wsClient.send({
|
|
98
|
+
type: 'agent:shutdown-warning',
|
|
99
|
+
payload: {
|
|
100
|
+
reason,
|
|
101
|
+
shutdownInSeconds: warningSeconds,
|
|
102
|
+
},
|
|
103
|
+
timestamp: Date.now(),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
this.shutdownTimer = setTimeout(() => {
|
|
107
|
+
this.executeShutdown(reason);
|
|
108
|
+
}, warningSeconds * 1_000);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Cancel a pending scheduled shutdown.
|
|
113
|
+
*/
|
|
114
|
+
cancelShutdown(): boolean {
|
|
115
|
+
if (!this.shutdownPending || !this.shutdownTimer) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
clearTimeout(this.shutdownTimer);
|
|
119
|
+
this.shutdownTimer = null;
|
|
120
|
+
this.shutdownPending = false;
|
|
121
|
+
|
|
122
|
+
this.logger.info('Scheduled shutdown cancelled');
|
|
123
|
+
this.wsClient.send({
|
|
124
|
+
type: 'agent:shutdown-cancelled',
|
|
125
|
+
payload: {},
|
|
126
|
+
timestamp: Date.now(),
|
|
127
|
+
});
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
isShutdownPending(): boolean {
|
|
132
|
+
return this.shutdownPending;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// --- Private ---
|
|
136
|
+
|
|
137
|
+
private checkSchedule(): void {
|
|
138
|
+
if (!this.config.shutdownCron || this.shutdownPending) return;
|
|
139
|
+
|
|
140
|
+
const now = this.getNowInTimezone();
|
|
141
|
+
if (this.matchesCron(this.config.shutdownCron, now)) {
|
|
142
|
+
this.triggerShutdown('local-schedule');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private executeShutdown(reason: string): void {
|
|
147
|
+
this.logger.warn(`Executing system shutdown (reason: ${reason})`);
|
|
148
|
+
|
|
149
|
+
// Notify server before going down
|
|
150
|
+
this.wsClient.send({
|
|
151
|
+
type: 'agent:shutting-down',
|
|
152
|
+
payload: { reason },
|
|
153
|
+
timestamp: Date.now(),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const platform = getPlatform();
|
|
157
|
+
let bin: string;
|
|
158
|
+
let args: string[];
|
|
159
|
+
|
|
160
|
+
switch (platform) {
|
|
161
|
+
case 'windows':
|
|
162
|
+
bin = 'shutdown';
|
|
163
|
+
args = ['/s', '/t', '5', '/c', `LIGHTMAN scheduled shutdown: ${reason}`];
|
|
164
|
+
break;
|
|
165
|
+
case 'darwin':
|
|
166
|
+
bin = 'shutdown';
|
|
167
|
+
args = ['-h', '+1'];
|
|
168
|
+
break;
|
|
169
|
+
default:
|
|
170
|
+
bin = 'shutdown';
|
|
171
|
+
args = ['-h', 'now'];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Give WS message time to send, then shut down
|
|
175
|
+
setTimeout(() => {
|
|
176
|
+
execFile(bin, args, (err) => {
|
|
177
|
+
if (err) {
|
|
178
|
+
this.logger.error('Shutdown exec failed:', err.message);
|
|
179
|
+
this.shutdownPending = false;
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}, 2_000);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Get current date/time components in the configured timezone.
|
|
187
|
+
*/
|
|
188
|
+
private getNowInTimezone(): { minute: number; hour: number; dayOfMonth: number; month: number; dayOfWeek: number } {
|
|
189
|
+
const tz = this.config.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
190
|
+
const now = new Date();
|
|
191
|
+
|
|
192
|
+
// Use Intl to get components in the target timezone
|
|
193
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
194
|
+
timeZone: tz,
|
|
195
|
+
hour: 'numeric',
|
|
196
|
+
minute: 'numeric',
|
|
197
|
+
day: 'numeric',
|
|
198
|
+
month: 'numeric',
|
|
199
|
+
weekday: 'short',
|
|
200
|
+
hour12: false,
|
|
201
|
+
}).formatToParts(now);
|
|
202
|
+
|
|
203
|
+
const get = (type: string) => {
|
|
204
|
+
const part = parts.find((p) => p.type === type);
|
|
205
|
+
return part ? parseInt(part.value, 10) : 0;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const weekdayStr = parts.find((p) => p.type === 'weekday')?.value || '';
|
|
209
|
+
const dayOfWeekMap: Record<string, number> = {
|
|
210
|
+
Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
minute: get('minute'),
|
|
215
|
+
hour: get('hour'),
|
|
216
|
+
dayOfMonth: get('day'),
|
|
217
|
+
month: get('month'),
|
|
218
|
+
dayOfWeek: dayOfWeekMap[weekdayStr] ?? 0,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Simple 5-field cron matcher: minute hour dayOfMonth month dayOfWeek
|
|
224
|
+
* Supports: *, numbers, ranges (1-5), steps (asterisk/5), lists (1,3,5)
|
|
225
|
+
*/
|
|
226
|
+
private matchesCron(
|
|
227
|
+
expr: string,
|
|
228
|
+
now: { minute: number; hour: number; dayOfMonth: number; month: number; dayOfWeek: number }
|
|
229
|
+
): boolean {
|
|
230
|
+
const fields = expr.trim().split(/\s+/);
|
|
231
|
+
if (fields.length !== 5) return false;
|
|
232
|
+
|
|
233
|
+
const values = [now.minute, now.hour, now.dayOfMonth, now.month, now.dayOfWeek];
|
|
234
|
+
|
|
235
|
+
return fields.every((field, i) => this.matchesCronField(field, values[i]));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private matchesCronField(field: string, value: number): boolean {
|
|
239
|
+
// Handle list: "1,3,5"
|
|
240
|
+
const parts = field.split(',');
|
|
241
|
+
return parts.some((part) => {
|
|
242
|
+
// Handle step: "*/5" or "1-10/2"
|
|
243
|
+
const [rangeOrWild, stepStr] = part.split('/');
|
|
244
|
+
const step = stepStr ? parseInt(stepStr, 10) : 1;
|
|
245
|
+
|
|
246
|
+
if (rangeOrWild === '*') {
|
|
247
|
+
return value % step === 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Handle range: "1-5"
|
|
251
|
+
if (rangeOrWild.includes('-')) {
|
|
252
|
+
const [lo, hi] = rangeOrWild.split('-').map(Number);
|
|
253
|
+
return value >= lo && value <= hi && (value - lo) % step === 0;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Single value
|
|
257
|
+
return parseInt(rangeOrWild, 10) === value;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|