jw-automator 2.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,19 +1,53 @@
1
- {
2
- "name": "jw-automator",
3
- "version": "2.0.0",
4
- "description": "Run various functions at proscribed intervals",
5
- "main": "automator.js",
6
- "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
8
- },
9
- "repository": {
10
- "type": "git",
11
- "url": "git+https://github.com/jonwyett/jw-automator.git"
12
- },
13
- "author": "Jonathan Wyett",
14
- "license": "ISC",
15
- "bugs": {
16
- "url": "https://github.com/jonwyett/jw-automator/issues"
17
- },
18
- "homepage": "https://github.com/jonwyett/jw-automator#readme"
19
- }
1
+ {
2
+ "name": "jw-automator",
3
+ "version": "3.1.0",
4
+ "description": "A resilient, local-time, 1-second precision automation scheduler for Node.js",
5
+ "main": "index.js",
6
+ "files": [
7
+ "src/",
8
+ "examples/",
9
+ "docs/",
10
+ "index.js",
11
+ "README.md",
12
+ "CHANGELOG.md"
13
+ ],
14
+ "scripts": {
15
+ "test": "jest",
16
+ "test:watch": "jest --watch",
17
+ "test:coverage": "jest --coverage"
18
+ },
19
+ "keywords": [
20
+ "scheduler",
21
+ "automation",
22
+ "cron",
23
+ "task",
24
+ "timer",
25
+ "schedule",
26
+ "local-time",
27
+ "dst",
28
+ "recurrence",
29
+ "iot",
30
+ "raspberry-pi",
31
+ "home-automation"
32
+ ],
33
+ "author": "Jon Wyett",
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/jonwyett/jw-automator.git"
38
+ },
39
+ "engines": {
40
+ "node": ">=12.0.0"
41
+ },
42
+ "devDependencies": {
43
+ "jest": "^29.0.0"
44
+ },
45
+ "jest": {
46
+ "testEnvironment": "node",
47
+ "coverageDirectory": "coverage",
48
+ "collectCoverageFrom": [
49
+ "src/**/*.js",
50
+ "!src/**/*.test.js"
51
+ ]
52
+ }
53
+ }
@@ -0,0 +1,476 @@
1
+ /**
2
+ * Automator.js
3
+ *
4
+ * Main API class for jw-automator v3
5
+ */
6
+
7
+ const SchedulerHost = require('./host/SchedulerHost');
8
+ const CoreEngine = require('./core/CoreEngine');
9
+ const FileStorage = require('./storage/FileStorage');
10
+ const MemoryStorage = require('./storage/MemoryStorage');
11
+
12
+ class Automator {
13
+ constructor(options = {}) {
14
+ this.options = {
15
+ storage: options.storage || new MemoryStorage(),
16
+ autoSave: options.autoSave !== false, // default true
17
+ saveInterval: options.saveInterval || 5000, // 5 seconds
18
+ ...options
19
+ };
20
+
21
+ this.host = new SchedulerHost();
22
+ this.nextId = 1;
23
+ this.saveTimer = null;
24
+
25
+ // Forward events from host
26
+ this.host.on('ready', (...args) => this._emit('ready', ...args));
27
+ this.host.on('action', (...args) => this._emit('action', ...args));
28
+ this.host.on('error', (...args) => this._emit('error', ...args));
29
+ this.host.on('debug', (...args) => this._emit('debug', ...args));
30
+
31
+ // Event listeners
32
+ this.listeners = new Map();
33
+
34
+ // Load initial state
35
+ this._loadState();
36
+ }
37
+
38
+ /**
39
+ * Start the automator
40
+ */
41
+ start() {
42
+ this.host.start();
43
+
44
+ // Start auto-save if enabled
45
+ if (this.options.autoSave) {
46
+ this._startAutoSave();
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Stop the automator
52
+ */
53
+ stop() {
54
+ this.host.stop();
55
+
56
+ // Stop auto-save
57
+ if (this.saveTimer) {
58
+ clearInterval(this.saveTimer);
59
+ this.saveTimer = null;
60
+ }
61
+
62
+ // Final save
63
+ if (this.options.autoSave) {
64
+ this._saveState();
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Register a command function
70
+ */
71
+ addFunction(name, fn) {
72
+ this.host.addFunction(name, fn);
73
+ }
74
+
75
+ /**
76
+ * Remove a command function
77
+ */
78
+ removeFunction(name) {
79
+ this.host.removeFunction(name);
80
+ }
81
+
82
+ /**
83
+ * Add an action
84
+ */
85
+ addAction(actionSpec) {
86
+ // Validate action
87
+ this._validateAction(actionSpec);
88
+
89
+ // Create action with state
90
+ const action = {
91
+ id: this.nextId++,
92
+ name: actionSpec.name || null,
93
+ cmd: actionSpec.cmd,
94
+ payload: actionSpec.payload !== undefined ? actionSpec.payload : null,
95
+ date: actionSpec.date ? new Date(actionSpec.date) : new Date(),
96
+ unBuffered: actionSpec.unBuffered || false,
97
+ repeat: actionSpec.repeat ? { ...actionSpec.repeat } : null,
98
+ count: 0
99
+ };
100
+
101
+ // Set default dstPolicy if not specified
102
+ if (action.repeat && !action.repeat.dstPolicy) {
103
+ action.repeat.dstPolicy = 'once';
104
+ }
105
+
106
+ this.host.addAction(action);
107
+
108
+ this._emit('update', {
109
+ type: 'update',
110
+ operation: 'add',
111
+ action: { ...action }
112
+ });
113
+
114
+ if (this.options.autoSave) {
115
+ this._saveState();
116
+ }
117
+
118
+ return action.id;
119
+ }
120
+
121
+ /**
122
+ * Update an action by ID
123
+ */
124
+ updateActionByID(id, updates) {
125
+ const state = this.host.getState();
126
+ const action = state.actions.find(a => a.id === id);
127
+
128
+ if (!action) {
129
+ throw new Error(`Action with id ${id} not found`);
130
+ }
131
+
132
+ // Update allowed fields
133
+ const allowedUpdates = ['name', 'cmd', 'payload', 'date', 'unBuffered', 'repeat', 'count'];
134
+
135
+ for (const key of allowedUpdates) {
136
+ if (key in updates) {
137
+ if (key === 'date' && updates[key]) {
138
+ action[key] = new Date(updates[key]);
139
+ } else if (key === 'repeat' && updates[key]) {
140
+ action[key] = { ...updates[key] };
141
+ if (!action[key].dstPolicy) {
142
+ action[key].dstPolicy = 'once';
143
+ }
144
+ } else {
145
+ action[key] = updates[key];
146
+ }
147
+ }
148
+ }
149
+
150
+ this._emit('update', {
151
+ type: 'update',
152
+ operation: 'update',
153
+ actionId: id,
154
+ action: { ...action }
155
+ });
156
+
157
+ if (this.options.autoSave) {
158
+ this._saveState();
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Update actions by name
164
+ */
165
+ updateActionByName(name, updates) {
166
+ const state = this.host.getState();
167
+ const toUpdate = state.actions.filter(a => a.name === name);
168
+
169
+ if (toUpdate.length === 0) {
170
+ // For consistency with removeActionByName, we could throw an error.
171
+ // However, it might be more convenient to simply return 0.
172
+ // Let's return 0 for now.
173
+ return 0;
174
+ }
175
+
176
+ const allowedUpdates = ['cmd', 'payload', 'date', 'unBuffered', 'repeat', 'count'];
177
+
178
+ for (const action of toUpdate) {
179
+ for (const key of allowedUpdates) {
180
+ if (key in updates) {
181
+ if (key === 'date' && updates[key]) {
182
+ action[key] = new Date(updates[key]);
183
+ } else if (key === 'repeat' && updates[key]) {
184
+ action[key] = { ...action.repeat, ...updates[key] };
185
+ if (!action[key].dstPolicy) {
186
+ action[key].dstPolicy = 'once';
187
+ }
188
+ } else {
189
+ action[key] = updates[key];
190
+ }
191
+ }
192
+ }
193
+
194
+ this._emit('update', {
195
+ type: 'update',
196
+ operation: 'update',
197
+ actionId: action.id,
198
+ action: { ...action }
199
+ });
200
+ }
201
+
202
+ if (this.options.autoSave) {
203
+ this._saveState();
204
+ }
205
+
206
+ return toUpdate.length;
207
+ }
208
+
209
+ /**
210
+ * Remove action by ID
211
+ */
212
+ removeActionByID(id) {
213
+ const state = this.host.getState();
214
+ const index = state.actions.findIndex(a => a.id === id);
215
+
216
+ if (index === -1) {
217
+ throw new Error(`Action with id ${id} not found`);
218
+ }
219
+
220
+ const removed = state.actions.splice(index, 1)[0];
221
+
222
+ this._emit('update', {
223
+ type: 'update',
224
+ operation: 'remove',
225
+ actionId: id,
226
+ action: removed
227
+ });
228
+
229
+ if (this.options.autoSave) {
230
+ this._saveState();
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Remove actions by name
236
+ */
237
+ removeActionByName(name) {
238
+ const state = this.host.getState();
239
+ const toRemove = state.actions.filter(a => a.name === name);
240
+
241
+ if (toRemove.length === 0) {
242
+ throw new Error(`No actions found with name: ${name}`);
243
+ }
244
+
245
+ for (const action of toRemove) {
246
+ const index = state.actions.indexOf(action);
247
+ if (index !== -1) {
248
+ state.actions.splice(index, 1);
249
+
250
+ this._emit('update', {
251
+ type: 'update',
252
+ operation: 'remove',
253
+ actionId: action.id,
254
+ action
255
+ });
256
+ }
257
+ }
258
+
259
+ if (this.options.autoSave) {
260
+ this._saveState();
261
+ }
262
+
263
+ return toRemove.length;
264
+ }
265
+
266
+ /**
267
+ * Get all actions (deep copy)
268
+ */
269
+ getActions() {
270
+ const state = this.host.getState();
271
+ return JSON.parse(JSON.stringify(state.actions));
272
+ }
273
+
274
+ /**
275
+ * Get actions by name
276
+ */
277
+ getActionsByName(name) {
278
+ const state = this.host.getState();
279
+ return state.actions
280
+ .filter(a => a.name === name)
281
+ .map(a => JSON.parse(JSON.stringify(a)));
282
+ }
283
+
284
+ /**
285
+ * Get action by ID
286
+ */
287
+ getActionByID(id) {
288
+ const state = this.host.getState();
289
+ const action = state.actions.find(a => a.id === id);
290
+ return action ? JSON.parse(JSON.stringify(action)) : null;
291
+ }
292
+
293
+ /**
294
+ * Get actions scheduled in a time range
295
+ */
296
+ getActionsInRange(startDate, endDate, callback) {
297
+ const start = new Date(startDate);
298
+ const end = new Date(endDate);
299
+
300
+ const state = this.host.getState();
301
+ const events = CoreEngine.simulate(state, start, end);
302
+
303
+ if (callback && typeof callback === 'function') {
304
+ callback(events);
305
+ }
306
+
307
+ return events;
308
+ }
309
+
310
+ /**
311
+ * Simulate range (alias for getActionsInRange)
312
+ */
313
+ simulateRange(startDate, endDate) {
314
+ return this.getActionsInRange(startDate, endDate);
315
+ }
316
+
317
+ /**
318
+ * Describe an action in human-readable format
319
+ */
320
+ describeAction(id) {
321
+ const action = this.getActionByID(id);
322
+ if (!action) {
323
+ return null;
324
+ }
325
+
326
+ let description = `Action #${action.id}`;
327
+ if (action.name) {
328
+ description += ` - ${action.name}`;
329
+ }
330
+
331
+ description += `\n Command: ${action.cmd}`;
332
+ description += `\n Next run: ${action.date ? action.date.toLocaleString() : 'None'}`;
333
+ description += `\n Executions: ${action.count}`;
334
+ description += `\n Buffered: ${!action.unBuffered}`;
335
+
336
+ if (action.repeat) {
337
+ description += `\n Recurrence: ${action.repeat.type}`;
338
+ if (action.repeat.interval > 1) {
339
+ description += ` (every ${action.repeat.interval})`;
340
+ }
341
+ if (action.repeat.limit) {
342
+ description += `\n Limit: ${action.repeat.limit}`;
343
+ }
344
+ if (action.repeat.endDate) {
345
+ description += `\n End date: ${new Date(action.repeat.endDate).toLocaleString()}`;
346
+ }
347
+ description += `\n DST policy: ${action.repeat.dstPolicy}`;
348
+ } else {
349
+ description += `\n Recurrence: One-time`;
350
+ }
351
+
352
+ return description;
353
+ }
354
+
355
+ /**
356
+ * Event listener management
357
+ */
358
+ on(event, listener) {
359
+ if (!this.listeners.has(event)) {
360
+ this.listeners.set(event, []);
361
+ }
362
+ this.listeners.get(event).push(listener);
363
+ }
364
+
365
+ off(event, listener) {
366
+ if (!this.listeners.has(event)) {
367
+ return;
368
+ }
369
+ const list = this.listeners.get(event);
370
+ const index = list.indexOf(listener);
371
+ if (index !== -1) {
372
+ list.splice(index, 1);
373
+ }
374
+ }
375
+
376
+ _emit(event, ...args) {
377
+ if (!this.listeners.has(event)) {
378
+ return;
379
+ }
380
+ const list = this.listeners.get(event);
381
+ for (const listener of list) {
382
+ try {
383
+ listener(...args);
384
+ } catch (error) {
385
+ console.error(`Error in ${event} listener:`, error);
386
+ }
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Load state from storage
392
+ */
393
+ _loadState() {
394
+ try {
395
+ const state = this.options.storage.load();
396
+ this.host.setState(state);
397
+
398
+ // Update nextId to be higher than any existing ID
399
+ if (state.actions && state.actions.length > 0) {
400
+ const maxId = Math.max(...state.actions.map(a => a.id || 0));
401
+ this.nextId = maxId + 1;
402
+ }
403
+ } catch (error) {
404
+ this._emit('error', {
405
+ type: 'error',
406
+ message: `Failed to load state: ${error.message}`,
407
+ error
408
+ });
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Save state to storage
414
+ */
415
+ _saveState() {
416
+ try {
417
+ const state = this.host.getState();
418
+ this.options.storage.save(state);
419
+ } catch (error) {
420
+ this._emit('error', {
421
+ type: 'error',
422
+ message: `Failed to save state: ${error.message}`,
423
+ error
424
+ });
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Start auto-save timer
430
+ */
431
+ _startAutoSave() {
432
+ if (this.saveTimer) {
433
+ return;
434
+ }
435
+
436
+ this.saveTimer = setInterval(() => {
437
+ this._saveState();
438
+ }, this.options.saveInterval);
439
+ }
440
+
441
+ /**
442
+ * Validate action specification
443
+ */
444
+ _validateAction(action) {
445
+ if (!action.cmd) {
446
+ throw new Error('Action must have a cmd property');
447
+ }
448
+
449
+ if (action.repeat) {
450
+ const validTypes = ['second', 'minute', 'hour', 'day', 'weekday', 'weekend', 'week', 'month', 'year'];
451
+ if (!validTypes.includes(action.repeat.type)) {
452
+ throw new Error(`Invalid repeat type: ${action.repeat.type}`);
453
+ }
454
+
455
+ if (action.repeat.interval !== undefined && action.repeat.interval < 1) {
456
+ throw new Error('Repeat interval must be >= 1');
457
+ }
458
+
459
+ if (action.repeat.dstPolicy && !['once', 'twice'].includes(action.repeat.dstPolicy)) {
460
+ throw new Error('DST policy must be "once" or "twice"');
461
+ }
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Static storage factory methods
467
+ */
468
+ static get storage() {
469
+ return {
470
+ file: (filePath) => new FileStorage(filePath),
471
+ memory: () => new MemoryStorage()
472
+ };
473
+ }
474
+ }
475
+
476
+ module.exports = Automator;