signalk-automation-plugin 0.0.5 → 0.0.7

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/lib/engine.js CHANGED
@@ -37,6 +37,12 @@ function addPathsFromAction (paths, action) {
37
37
  if (!action || typeof action !== 'object') return
38
38
  if (action.put) paths.add(action.put)
39
39
  if (action.valueFrom) paths.add(action.valueFrom)
40
+ const gate = action.if
41
+ if (Array.isArray(gate)) {
42
+ for (const c of gate) addPathsFromClause(paths, c)
43
+ } else if (gate) {
44
+ addPathsFromClause(paths, gate)
45
+ }
40
46
  }
41
47
 
42
48
  function getByPath (values, p) {
@@ -306,6 +312,31 @@ function resultLabel (result) {
306
312
  return result === 'failure' ? 'Failed' : 'OK'
307
313
  }
308
314
 
315
+ function formatStream (text) {
316
+ const t = String(text == null ? '' : text).replace(/\s+$/, '')
317
+ if (!t) return '(empty)'
318
+ if (!t.includes('\n')) return t
319
+ return '\n' + t.split('\n').map((line) => ' ' + line).join('\n')
320
+ }
321
+
322
+ function actionStderr (action) {
323
+ if (action && action.stderr) return String(action.stderr)
324
+ const err = action && action.error ? String(action.error) : ''
325
+ if (err.startsWith('stderr: ')) return err.slice(8)
326
+ return ''
327
+ }
328
+
329
+ function appendRunActionLines (lines, action) {
330
+ const extra = (action.args && action.args.length) ? ' ' + action.args.join(' ') : ''
331
+ lines.push(' run ' + (action.file || '') + extra)
332
+ lines.push(' stdout: ' + formatStream(action.stdout))
333
+ lines.push(' stderr: ' + formatStream(actionStderr(action)))
334
+ const err = action.error ? String(action.error) : ''
335
+ if (err && !err.startsWith('stderr: ')) {
336
+ lines.push(' error: ' + err)
337
+ }
338
+ }
339
+
309
340
  function formatVerboseLog (auto, record) {
310
341
  const name = (auto && (auto.alias || auto.id)) || (record && record.id) || 'automation'
311
342
  const result = resultLabel(record.result)
@@ -327,6 +358,9 @@ function formatVerboseLog (auto, record) {
327
358
  appendClauseLines(lines, c, ' ')
328
359
  }
329
360
  }
361
+ for (const a of record.actions || []) {
362
+ if (a && a.type === 'run') appendRunActionLines(lines, a)
363
+ }
330
364
  return lines.join('\n')
331
365
  }
332
366
 
@@ -475,6 +509,11 @@ async function evaluateAutomation (auto, ctx) {
475
509
  }
476
510
 
477
511
  for (const action of actions) {
512
+ if (ctx.abort && ctx.abort.aborted) {
513
+ record.result = 'ok'
514
+ record.reason = 'superseded'
515
+ return finishRecord(auto, record)
516
+ }
478
517
  const ran = await runAction(action, ctx)
479
518
  record.actions.push(ran)
480
519
  if (!ran.ok) {
@@ -482,6 +521,7 @@ async function evaluateAutomation (auto, ctx) {
482
521
  record.reason = ran.error
483
522
  return finishRecord(auto, record)
484
523
  }
524
+ if (ran.stop) break
485
525
  }
486
526
  record.result = 'ok'
487
527
  return finishRecord(auto, record)
@@ -491,6 +531,12 @@ async function runAction (action, ctx) {
491
531
  if (!action || typeof action !== 'object') {
492
532
  return { ok: false, error: 'empty action' }
493
533
  }
534
+ if (action.if) {
535
+ const gate = await evalListAll(Array.isArray(action.if) ? action.if : [action.if], ctx)
536
+ if (!gate.pass) {
537
+ return { ok: true, skipped: true, reason: 'if not met', if: gate.results }
538
+ }
539
+ }
494
540
  if (action.put) {
495
541
  try {
496
542
  const value = Object.prototype.hasOwnProperty.call(action, 'valueFrom')
@@ -533,20 +579,46 @@ async function runAction (action, ctx) {
533
579
  }
534
580
  if (action.delay != null) {
535
581
  const ms = parseDuration(action.delay)
536
- if (ctx.skipDelay) return { ok: true, type: 'delay', ms, skipped: true }
537
- await ctx.sleep(ms)
582
+ if (ctx.skipDelay) return { ok: true, type: 'delay', ms, skipped: true, stop: true }
583
+ try {
584
+ await ctx.sleep(ms)
585
+ } catch (err) {
586
+ if (err && err.cancelled) {
587
+ return { ok: true, type: 'delay', ms, cancelled: true, stop: true }
588
+ }
589
+ throw err
590
+ }
591
+ if (ctx.abort && ctx.abort.aborted) {
592
+ return { ok: true, type: 'delay', ms, cancelled: true, stop: true }
593
+ }
538
594
  return { ok: true, type: 'delay', ms }
539
595
  }
540
596
  if (action.run) {
541
597
  const spec = typeof action.run === 'string' ? { file: action.run } : action.run
542
598
  const ran = await ctx.runScript(spec)
543
599
  if (!ran.ok) {
544
- return { ok: false, type: 'run', file: spec.file, error: ran.error, stdout: ran.stdout }
600
+ return {
601
+ ok: false,
602
+ type: 'run',
603
+ file: spec.file,
604
+ args: spec.args || [],
605
+ error: ran.error,
606
+ stdout: ran.stdout,
607
+ stderr: ran.stderr || ''
608
+ }
545
609
  }
546
610
  if (action.as) {
547
611
  ctx.setHelper(action.as, ran.value)
548
612
  }
549
- return { ok: true, type: 'run', file: spec.file, value: ran.value, stdout: ran.stdout }
613
+ return {
614
+ ok: true,
615
+ type: 'run',
616
+ file: spec.file,
617
+ args: spec.args || [],
618
+ value: ran.value,
619
+ stdout: ran.stdout,
620
+ stderr: ran.stderr || ''
621
+ }
550
622
  }
551
623
  return { ok: false, error: 'unknown action' }
552
624
  }
package/lib/runtime.js CHANGED
@@ -21,6 +21,7 @@ class Runtime {
21
21
  this.sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
22
22
  this.putFn = opts.put
23
23
  this.notifyFn = opts.notify
24
+ this.onHelper = opts.onHelper || null
24
25
  this.log = opts.log || { debug () {}, error () {} }
25
26
 
26
27
  this.doc = yaml.emptyDoc()
@@ -33,6 +34,7 @@ class Runtime {
33
34
  this.lastCronMinute = ''
34
35
  this.lastQuantized = {}
35
36
  this.unsubscribes = []
37
+ this.inFlight = {}
36
38
  }
37
39
 
38
40
  load () {
@@ -89,11 +91,20 @@ class Runtime {
89
91
  to: coerced,
90
92
  changed: true
91
93
  })
94
+ this.emitHelperValue(id, coerced)
92
95
  if (!opts || opts.kick !== false) {
93
96
  await this.kickPath(helpers.helperPath(id), coerced)
94
97
  }
95
98
  }
96
- return coerced
99
+ const current = Object.prototype.hasOwnProperty.call(this.helperValues, id)
100
+ ? this.helperValues[id]
101
+ : coerced
102
+ if (!engine.sameValue(current, coerced)) this.emitHelperValue(id, current)
103
+ return current
104
+ }
105
+
106
+ emitHelperValue (id, value) {
107
+ if (typeof this.onHelper === 'function') this.onHelper(id, value)
97
108
  }
98
109
 
99
110
  async resetHelper (id) {
@@ -162,16 +173,21 @@ class Runtime {
162
173
  const key = d.getFullYear() + '-' + d.getMonth() + '-' + d.getDate() + '-' + d.getHours() + '-' + d.getMinutes()
163
174
  if (key === this.lastCronMinute) return
164
175
  this.lastCronMinute = key
176
+ const runs = []
165
177
  for (const auto of this.doc.automations) {
166
178
  const schedules = (auto.trigger || []).filter((t) => t.schedule)
167
179
  if (!schedules.length) continue
168
180
  const hit = schedules.some((t) => engine.cronMatch(t.schedule, d))
169
- if (hit) await this.maybeRun(auto, { schedule: schedules[0].schedule })
181
+ if (hit) runs.push(this.maybeRun(auto, { schedule: schedules[0].schedule }))
170
182
  }
183
+ await Promise.all(runs)
171
184
  }
172
185
 
173
186
  async maybeRun (auto, trigger) {
174
187
  if (!this.isEnabled(auto)) return
188
+ const mode = auto.mode || 'parallel'
189
+ if (mode === 'single' && this.inFlight[auto.id]) return
190
+ if (mode === 'restart' && this.inFlight[auto.id]) this.inFlight[auto.id].abort()
175
191
  const ctx = this.makeCtx(trigger)
176
192
  const trig = await engine.evalListAll(auto.trigger || [], ctx)
177
193
  const matched = engine.triggerListMatched(auto.trigger, trig.results)
@@ -199,9 +215,15 @@ class Runtime {
199
215
  }
200
216
 
201
217
  this.lastDebounce[auto.id] = this.now()
202
- const record = await engine.evaluateAutomation(auto, this.makeCtx(trigger))
203
- this.finishRun(auto, record, { forceTrace: false })
204
- return record
218
+ const abort = newAbort()
219
+ if (mode === 'single' || mode === 'restart') this.inFlight[auto.id] = abort
220
+ try {
221
+ const record = await engine.evaluateAutomation(auto, this.makeCtx(trigger, { abort }))
222
+ this.finishRun(auto, record, { forceTrace: false })
223
+ return record
224
+ } finally {
225
+ if (this.inFlight[auto.id] === abort) delete this.inFlight[auto.id]
226
+ }
205
227
  }
206
228
 
207
229
  keepTrace (auto, record, extra) {
@@ -237,11 +259,13 @@ class Runtime {
237
259
 
238
260
  async evaluateOnStart () {
239
261
  this.seedFromSelf()
262
+ const runs = []
240
263
  for (const auto of this.doc.automations) {
241
264
  if (!this.isEnabled(auto)) continue
242
265
  if (this.scheduleOnly(auto)) continue
243
- await this.maybeRun(auto, { start: true })
266
+ runs.push(this.maybeRun(auto, { start: true }))
244
267
  }
268
+ await Promise.all(runs)
245
269
  }
246
270
 
247
271
  seedFromSelf () {
@@ -318,6 +342,24 @@ class Runtime {
318
342
 
319
343
  makeCtx (trigger, extra) {
320
344
  extra = extra || {}
345
+ const abort = extra.abort
346
+ const sleep = async (ms) => {
347
+ if (!abort) return this.sleep(ms)
348
+ try {
349
+ await Promise.race([
350
+ this.sleep(ms),
351
+ abort.promise
352
+ ])
353
+ } catch (err) {
354
+ if (err && err.cancelled) throw err
355
+ throw err
356
+ }
357
+ if (abort.aborted) {
358
+ const err = new Error('superseded')
359
+ err.cancelled = true
360
+ throw err
361
+ }
362
+ }
321
363
  return {
322
364
  values: this.values,
323
365
  zones: this.doc.zones,
@@ -326,10 +368,11 @@ class Runtime {
326
368
  now: this.now(),
327
369
  skipDelay: !!extra.skipDelay,
328
370
  skipUnchanged: extra.skipUnchanged !== false,
371
+ abort: abort || null,
329
372
  put: (p, value) => this.put(p, value),
330
373
  notify: (p, state, message) => this.notify(p, state, message),
331
374
  setHelper: (id, value) => this.setHelper(id, value, { kick: false }),
332
- sleep: this.sleep,
375
+ sleep,
333
376
  runScript: engine.makeRunScript({
334
377
  scriptsDir: this.scriptsDir,
335
378
  timeoutMs: this.scriptTimeoutMs
@@ -446,6 +489,24 @@ class Runtime {
446
489
  }
447
490
  }
448
491
 
492
+ function newAbort () {
493
+ const abort = {
494
+ aborted: false,
495
+ abort () {
496
+ if (this.aborted) return
497
+ this.aborted = true
498
+ const err = new Error('superseded')
499
+ err.cancelled = true
500
+ this._reject(err)
501
+ }
502
+ }
503
+ abort.promise = new Promise((_, reject) => {
504
+ abort._reject = reject
505
+ })
506
+ abort.promise.catch(() => {})
507
+ return abort
508
+ }
509
+
449
510
  function asSwitch (v) {
450
511
  if (v === true || v === 1 || v === '1' || v === 'on' || v === 'ON') return 1
451
512
  if (v === false || v === 0 || v === '0' || v === 'off' || v === 'OFF') return 0
package/lib/yaml.js CHANGED
@@ -172,8 +172,15 @@ function mergeAutomations (result, list, file) {
172
172
  action: asArray(raw.action),
173
173
  choose: Array.isArray(raw.choose) ? raw.choose : [],
174
174
  debounceMs: raw.debounceMs || 0,
175
- edge: raw.edge || 'every'
175
+ edge: raw.edge || 'every',
176
+ mode: raw.mode || 'parallel'
176
177
  })
178
+ if (raw.mode && ['parallel', 'restart', 'single'].indexOf(raw.mode) === -1) {
179
+ result.errors.push({
180
+ file,
181
+ message: 'Automation ' + raw.id + ': mode must be parallel, restart, or single'
182
+ })
183
+ }
177
184
  for (const t of asArray(raw.trigger)) {
178
185
  if (!t || t.round == null) continue
179
186
  const n = Number(t.round)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-automation-plugin",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Home Assistant-like YAML automations for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "keywords": [
package/plugin/index.js CHANGED
@@ -426,6 +426,7 @@ module.exports = function (app) {
426
426
  scriptTimeoutSeconds: options.scriptTimeoutSeconds,
427
427
  put: putPath,
428
428
  notify,
429
+ onHelper: (id, value) => emitHelper(id, value),
429
430
  log: {
430
431
  debug: app.debug ? app.debug.bind(app) : () => {},
431
432
  error: app.error ? app.error.bind(app) : console.error,
@@ -461,7 +462,10 @@ module.exports = function (app) {
461
462
  app.registerPutHandler('vessels.self', helpers.helperPath(id), (context, p, v, cb) => {
462
463
  Promise.resolve(runtime.setHelper(id, v))
463
464
  .then((next) => {
464
- emitHelper(id, next)
465
+ const live = Object.prototype.hasOwnProperty.call(runtime.helperValues, id)
466
+ ? runtime.helperValues[id]
467
+ : next
468
+ emitHelper(id, live)
465
469
  if (cb) cb({ state: 'COMPLETED' })
466
470
  })
467
471
  .catch((err) => {
package/public/app.js CHANGED
@@ -98,7 +98,10 @@ function fmtActions (t) {
98
98
  if (a.type === 'helper') return 'helper ' + a.helper + ' = ' + fmtVal(a.value)
99
99
  if (a.type === 'notify') return 'notify ' + (a.path || '') + ' ' + (a.state || '')
100
100
  if (a.type === 'delay') return a.skipped ? 'delay skipped (' + a.ms + ' ms)' : 'delay ' + a.ms + ' ms'
101
- if (a.type === 'run') return 'run ' + (a.file || '')
101
+ if (a.type === 'run') {
102
+ const extra = (a.args && a.args.length) ? ' ' + a.args.join(' ') : ''
103
+ return 'run ' + (a.file || '') + extra
104
+ }
102
105
  return a.type || 'action'
103
106
  }).join('\n')
104
107
  }
package/public/index.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>Signal K Automations</title>
7
7
  <link rel="icon" href="icon.png" />
8
- <link rel="stylesheet" href="style.css?v=20260918ok" />
8
+ <link rel="stylesheet" href="style.css?v=20260918args" />
9
9
  </head>
10
10
  <body>
11
11
  <header>
@@ -61,6 +61,6 @@
61
61
  </div>
62
62
  </section>
63
63
  </main>
64
- <script src="app.js?v=20260918ok"></script>
64
+ <script src="app.js?v=20260918args"></script>
65
65
  </body>
66
66
  </html>