signalk-automation-plugin 0.0.4 → 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/README.md CHANGED
@@ -36,34 +36,34 @@ Do not paste automations into the admin form. Runtime state is `~/.signalk/plugi
36
36
 
37
37
  ## Example
38
38
 
39
- See `examples/automations.yaml`. Boatnet live YAML is `git@github.com:htool/boatnet_automations.git` (`automationsDir` on the Pi). On/off and Verbose are plugin-data switches, not YAML keys.
39
+ See `examples/automations.yaml` (fictional sample). Live YAML is whatever directory you set as `automationsDir` — usually a separate git. On/off and Verbose are plugin-data switches, not YAML keys.
40
40
 
41
41
  ```yaml
42
42
  helpers:
43
- starlink_manual:
43
+ dish_manual:
44
44
  type: boolean
45
45
  default: false
46
46
  on_start: restore # survive reboot
47
- latch_on_external_put: electrical.switches.starlink.state
47
+ latch_on_external_put: electrical.switches.dish.state
48
48
 
49
49
  automations:
50
- - id: starlink_standby
50
+ - id: dish_standby
51
51
  trigger:
52
52
  - schedule: "0 4 * * *"
53
53
  condition:
54
54
  - zone: home_harbour
55
- - helper: starlink_manual
55
+ - helper: dish_manual
56
56
  is: false
57
57
  action:
58
- - put: electrical.switches.starlink.state
58
+ - put: electrical.switches.dish.state
59
59
  value: true
60
60
  - delay: 15m
61
- - put: electrical.switches.starlink.state
61
+ - put: electrical.switches.dish.state
62
62
  value: false
63
63
  ```
64
64
 
65
65
  ```yaml
66
- - path: electrical.switches.dolphinCharger.voltage
66
+ - path: electrical.switches.charger.voltage
67
67
  round: 1 # nearest volt; 230.1 and 230.4 do not both fire
68
68
  ```
69
69
 
@@ -1,103 +1,105 @@
1
+ # Bundled sample only. Site YAML belongs in the git you set as automationsDir,
2
+ # not in this plugin.
3
+
1
4
  helpers:
2
- starlink_manual:
5
+ dish_manual:
3
6
  type: boolean
4
- name: Starlink handmatig
7
+ name: Dish manual
5
8
  default: false
6
9
  on_start: restore
7
- latch_on_external_put: electrical.switches.starlink.state
8
- depart_prep:
10
+ latch_on_external_put: electrical.switches.dish.state
11
+ trip_prep:
9
12
  type: boolean
10
- name: Maak klaar voor vertrek
13
+ name: Prepare to leave
11
14
  default: false
12
15
  on_start: restore
13
16
 
14
17
  automations:
15
18
  - id: shore_charge
16
- alias: Walstroom laadbeleid
19
+ alias: Shore charge
17
20
  zones:
18
21
  home_harbour:
19
- name: Thuishaven
20
- lat: 52.37
21
- lon: 5.22
22
+ name: Home harbour
23
+ lat: 52.1
24
+ lon: 4.9
22
25
  radius: 150
23
26
  trigger:
24
- - path: sensors.presence.dolphinshelly
27
+ - path: sensors.presence.shore
25
28
  - path: electrical.batteries.house.capacity.stateOfCharge
26
29
  round: 0.01
27
- - helper: depart_prep
30
+ - helper: trip_prep
28
31
  choose:
29
- - alias: onderweg volladen
32
+ - alias: charge underway
30
33
  conditions:
31
- - path: sensors.presence.dolphinshelly
34
+ - path: sensors.presence.shore
32
35
  is: true
33
36
  - not:
34
37
  zone: home_harbour
35
38
  action:
36
- - put: electrical.switches.dolphinCharger.state
39
+ - put: electrical.switches.charger.state
37
40
  value: 1
38
- - alias: klaar voor vertrek laden
41
+ - alias: charge to leave
39
42
  conditions:
40
- - path: sensors.presence.dolphinshelly
43
+ - path: sensors.presence.shore
41
44
  is: true
42
45
  - zone: home_harbour
43
- - helper: depart_prep
46
+ - helper: trip_prep
44
47
  is: true
45
48
  - path: electrical.batteries.house.capacity.stateOfCharge
46
49
  below: 0.98
47
50
  action:
48
- - put: electrical.switches.dolphinCharger.state
51
+ - put: electrical.switches.charger.state
49
52
  value: 1
50
- - alias: klaar voor vertrek vol
53
+ - alias: leave charged
51
54
  conditions:
52
- - path: sensors.presence.dolphinshelly
55
+ - path: sensors.presence.shore
53
56
  is: true
54
57
  - zone: home_harbour
55
- - helper: depart_prep
58
+ - helper: trip_prep
56
59
  is: true
57
60
  - path: electrical.batteries.house.capacity.stateOfCharge
58
61
  above: 0.97
59
62
  action:
60
- - put: electrical.switches.dolphinCharger.state
63
+ - put: electrical.switches.charger.state
61
64
  value: 0
62
- - helper: depart_prep
65
+ - helper: trip_prep
63
66
  value: false
64
- - alias: thuis onder 60%
67
+ - alias: home below 60%
65
68
  conditions:
66
- - path: sensors.presence.dolphinshelly
69
+ - path: sensors.presence.shore
67
70
  is: true
68
71
  - zone: home_harbour
69
- - helper: depart_prep
72
+ - helper: trip_prep
70
73
  is: false
71
74
  - path: electrical.batteries.house.capacity.stateOfCharge
72
75
  below: 0.6
73
76
  action:
74
- - put: electrical.switches.dolphinCharger.state
77
+ - put: electrical.switches.charger.state
75
78
  value: 1
76
- - alias: thuis boven 80%
79
+ - alias: home above 80%
77
80
  conditions:
78
- - path: sensors.presence.dolphinshelly
81
+ - path: sensors.presence.shore
79
82
  is: true
80
83
  - zone: home_harbour
81
- - helper: depart_prep
84
+ - helper: trip_prep
82
85
  is: false
83
86
  - path: electrical.batteries.house.capacity.stateOfCharge
84
87
  above: 0.8
85
88
  action:
86
- - put: electrical.switches.dolphinCharger.state
89
+ - put: electrical.switches.charger.state
87
90
  value: 0
88
91
 
89
- - id: starlink_standby
90
- alias: Starlink standby-update
92
+ - id: dish_standby
93
+ alias: Dish standby update
91
94
  trigger:
92
95
  - schedule: "0 4 * * *"
93
96
  condition:
94
- # home_harbour is defined on shore_charge (Walstroom laadbeleid).
95
97
  - zone: home_harbour
96
- - helper: starlink_manual
98
+ - helper: dish_manual
97
99
  is: false
98
100
  action:
99
- - put: electrical.switches.starlink.state
101
+ - put: electrical.switches.dish.state
100
102
  value: true
101
103
  - delay: 15m
102
- - put: electrical.switches.starlink.state
104
+ - put: electrical.switches.dish.state
103
105
  value: false
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.4",
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/plugin/index.js CHANGED
@@ -305,15 +305,36 @@ module.exports = function (app) {
305
305
  })
306
306
  }
307
307
 
308
- function putPath (p, value) {
309
- return new Promise((resolve, reject) => {
310
- if (typeof app.putSelfPath !== 'function') {
311
- app.handleMessage(PLUGIN_ID, {
312
- updates: [{ values: [{ path: p, value }] }]
313
- })
314
- resolve()
315
- return
308
+ function isMultipleSources (err) {
309
+ return /multiple sources for the given path/i.test(String((err && err.message) || err || ''))
310
+ }
311
+
312
+ function putSources (p) {
313
+ const out = []
314
+ const seen = Object.create(null)
315
+ const add = (s) => {
316
+ if (!s) return
317
+ const id = String(s)
318
+ if (seen[id] || id.includes(PLUGIN_ID)) return
319
+ seen[id] = true
320
+ out.push(id)
321
+ }
322
+ const node = typeof app.getSelfPath === 'function' ? app.getSelfPath(p) : null
323
+ if (node && typeof node === 'object' && !Array.isArray(node)) {
324
+ add(node.$source)
325
+ const vals = node.values
326
+ if (vals && typeof vals === 'object') {
327
+ Object.keys(vals)
328
+ .sort((a, b) => Number(/^mqtt\b/i.test(b)) - Number(/^mqtt\b/i.test(a)))
329
+ .forEach(add)
316
330
  }
331
+ }
332
+ if (runtime && runtime.values) add(runtime.values[p + '.$source'])
333
+ return out
334
+ }
335
+
336
+ function callPutSelf (p, value, source) {
337
+ return new Promise((resolve, reject) => {
317
338
  let settled = false
318
339
  const finish = (err) => {
319
340
  if (settled) return
@@ -332,7 +353,9 @@ module.exports = function (app) {
332
353
  finish(null)
333
354
  }
334
355
  try {
335
- const ret = app.putSelfPath(p, value, fromReply)
356
+ const ret = source
357
+ ? app.putSelfPath(p, value, fromReply, source)
358
+ : app.putSelfPath(p, value, fromReply)
336
359
  if (ret && typeof ret.then === 'function') {
337
360
  ret.then(fromReply, finish)
338
361
  }
@@ -342,6 +365,30 @@ module.exports = function (app) {
342
365
  })
343
366
  }
344
367
 
368
+ async function putPath (p, value) {
369
+ if (typeof app.putSelfPath !== 'function') {
370
+ app.handleMessage(PLUGIN_ID, {
371
+ updates: [{ values: [{ path: p, value }] }]
372
+ })
373
+ return
374
+ }
375
+ try {
376
+ await callPutSelf(p, value)
377
+ } catch (err) {
378
+ if (!isMultipleSources(err)) throw err
379
+ let last = err
380
+ for (const source of putSources(p)) {
381
+ try {
382
+ await callPutSelf(p, value, source)
383
+ return
384
+ } catch (e) {
385
+ last = e
386
+ }
387
+ }
388
+ throw last
389
+ }
390
+ }
391
+
345
392
  function notify (p, state, message) {
346
393
  const npath = String(p).startsWith('notifications.') ? p : 'notifications.' + p
347
394
  app.handleMessage(PLUGIN_ID, {
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>