signalk-automation-plugin 0.0.5 → 0.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/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
@@ -33,6 +33,7 @@ class Runtime {
33
33
  this.lastCronMinute = ''
34
34
  this.lastQuantized = {}
35
35
  this.unsubscribes = []
36
+ this.inFlight = {}
36
37
  }
37
38
 
38
39
  load () {
@@ -162,16 +163,21 @@ class Runtime {
162
163
  const key = d.getFullYear() + '-' + d.getMonth() + '-' + d.getDate() + '-' + d.getHours() + '-' + d.getMinutes()
163
164
  if (key === this.lastCronMinute) return
164
165
  this.lastCronMinute = key
166
+ const runs = []
165
167
  for (const auto of this.doc.automations) {
166
168
  const schedules = (auto.trigger || []).filter((t) => t.schedule)
167
169
  if (!schedules.length) continue
168
170
  const hit = schedules.some((t) => engine.cronMatch(t.schedule, d))
169
- if (hit) await this.maybeRun(auto, { schedule: schedules[0].schedule })
171
+ if (hit) runs.push(this.maybeRun(auto, { schedule: schedules[0].schedule }))
170
172
  }
173
+ await Promise.all(runs)
171
174
  }
172
175
 
173
176
  async maybeRun (auto, trigger) {
174
177
  if (!this.isEnabled(auto)) return
178
+ const mode = auto.mode || 'parallel'
179
+ if (mode === 'single' && this.inFlight[auto.id]) return
180
+ if (mode === 'restart' && this.inFlight[auto.id]) this.inFlight[auto.id].abort()
175
181
  const ctx = this.makeCtx(trigger)
176
182
  const trig = await engine.evalListAll(auto.trigger || [], ctx)
177
183
  const matched = engine.triggerListMatched(auto.trigger, trig.results)
@@ -199,9 +205,15 @@ class Runtime {
199
205
  }
200
206
 
201
207
  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
208
+ const abort = newAbort()
209
+ if (mode === 'single' || mode === 'restart') this.inFlight[auto.id] = abort
210
+ try {
211
+ const record = await engine.evaluateAutomation(auto, this.makeCtx(trigger, { abort }))
212
+ this.finishRun(auto, record, { forceTrace: false })
213
+ return record
214
+ } finally {
215
+ if (this.inFlight[auto.id] === abort) delete this.inFlight[auto.id]
216
+ }
205
217
  }
206
218
 
207
219
  keepTrace (auto, record, extra) {
@@ -237,11 +249,13 @@ class Runtime {
237
249
 
238
250
  async evaluateOnStart () {
239
251
  this.seedFromSelf()
252
+ const runs = []
240
253
  for (const auto of this.doc.automations) {
241
254
  if (!this.isEnabled(auto)) continue
242
255
  if (this.scheduleOnly(auto)) continue
243
- await this.maybeRun(auto, { start: true })
256
+ runs.push(this.maybeRun(auto, { start: true }))
244
257
  }
258
+ await Promise.all(runs)
245
259
  }
246
260
 
247
261
  seedFromSelf () {
@@ -318,6 +332,24 @@ class Runtime {
318
332
 
319
333
  makeCtx (trigger, extra) {
320
334
  extra = extra || {}
335
+ const abort = extra.abort
336
+ const sleep = async (ms) => {
337
+ if (!abort) return this.sleep(ms)
338
+ try {
339
+ await Promise.race([
340
+ this.sleep(ms),
341
+ abort.promise
342
+ ])
343
+ } catch (err) {
344
+ if (err && err.cancelled) throw err
345
+ throw err
346
+ }
347
+ if (abort.aborted) {
348
+ const err = new Error('superseded')
349
+ err.cancelled = true
350
+ throw err
351
+ }
352
+ }
321
353
  return {
322
354
  values: this.values,
323
355
  zones: this.doc.zones,
@@ -326,10 +358,11 @@ class Runtime {
326
358
  now: this.now(),
327
359
  skipDelay: !!extra.skipDelay,
328
360
  skipUnchanged: extra.skipUnchanged !== false,
361
+ abort: abort || null,
329
362
  put: (p, value) => this.put(p, value),
330
363
  notify: (p, state, message) => this.notify(p, state, message),
331
364
  setHelper: (id, value) => this.setHelper(id, value, { kick: false }),
332
- sleep: this.sleep,
365
+ sleep,
333
366
  runScript: engine.makeRunScript({
334
367
  scriptsDir: this.scriptsDir,
335
368
  timeoutMs: this.scriptTimeoutMs
@@ -446,6 +479,24 @@ class Runtime {
446
479
  }
447
480
  }
448
481
 
482
+ function newAbort () {
483
+ const abort = {
484
+ aborted: false,
485
+ abort () {
486
+ if (this.aborted) return
487
+ this.aborted = true
488
+ const err = new Error('superseded')
489
+ err.cancelled = true
490
+ this._reject(err)
491
+ }
492
+ }
493
+ abort.promise = new Promise((_, reject) => {
494
+ abort._reject = reject
495
+ })
496
+ abort.promise.catch(() => {})
497
+ return abort
498
+ }
499
+
449
500
  function asSwitch (v) {
450
501
  if (v === true || v === 1 || v === '1' || v === 'on' || v === 'ON') return 1
451
502
  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.6",
4
4
  "description": "Home Assistant-like YAML automations for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "keywords": [
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>