signalk-automation-plugin 0.0.3 → 0.0.5

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
@@ -69,7 +69,7 @@ function evalClause (clause, ctx) {
69
69
  }
70
70
  }
71
71
  if (Array.isArray(clause.all)) {
72
- const parts = clause.all.map((c) => evalClause(c, ctx))
72
+ const parts = clause.all.map((c) => withLabel(c, evalClause(c, ctx)))
73
73
  return {
74
74
  pass: parts.every((p) => p.pass),
75
75
  error: parts.find((p) => p.error)?.error || null,
@@ -78,10 +78,11 @@ function evalClause (clause, ctx) {
78
78
  }
79
79
  }
80
80
  if (Array.isArray(clause.any)) {
81
- const parts = clause.any.map((c) => evalClause(c, ctx))
81
+ const parts = clause.any.map((c) => withLabel(c, evalClause(c, ctx)))
82
+ const pass = parts.some((p) => p.pass)
82
83
  return {
83
- pass: parts.some((p) => p.pass),
84
- error: parts.find((p) => p.error)?.error || null,
84
+ pass,
85
+ error: pass ? null : (parts.find((p) => p.error)?.error || null),
85
86
  actual: parts,
86
87
  clause: { any: true }
87
88
  }
@@ -131,6 +132,18 @@ function evalClause (clause, ctx) {
131
132
  return { pass: false, error: 'clause needs path, helper, zone, or run', actual: null }
132
133
  }
133
134
  const actual = getByPath(ctx.values, path)
135
+ const needsValue = ['is', 'equals', 'above', 'below', 'to'].some((k) =>
136
+ Object.prototype.hasOwnProperty.call(clause, k)
137
+ )
138
+ if (needsValue && (actual === undefined || actual === null)) {
139
+ return {
140
+ pass: false,
141
+ error: 'missing',
142
+ actual,
143
+ path,
144
+ expected: expectedOf(clause)
145
+ }
146
+ }
134
147
  let pass = false
135
148
  if (Object.prototype.hasOwnProperty.call(clause, 'is')) {
136
149
  pass = actual === clause.is
@@ -228,10 +241,20 @@ function clauseLabel (clause, result) {
228
241
 
229
242
  function markPass (r) {
230
243
  if (!r) return '✗'
231
- if (r.error) return '✗'
244
+ if (r.error && !r.pass) return '✗'
232
245
  return r.pass ? '✓' : '✗'
233
246
  }
234
247
 
248
+ function appendClauseLines (lines, r, indent) {
249
+ if (!r) return
250
+ lines.push(indent + markPass(r) + ' ' + (r.label || ''))
251
+ const inner = r.clause && (r.clause.any || r.clause.all)
252
+ if (!inner || !Array.isArray(r.actual)) return
253
+ for (const part of r.actual) {
254
+ appendClauseLines(lines, part, indent + ' ')
255
+ }
256
+ }
257
+
235
258
  function withLabel (clause, result) {
236
259
  const row = result && typeof result === 'object' ? result : { pass: false }
237
260
  row.clause = clause
@@ -279,9 +302,13 @@ async function evalListAll (list, ctx) {
279
302
  return { pass, results }
280
303
  }
281
304
 
305
+ function resultLabel (result) {
306
+ return result === 'failure' ? 'Failed' : 'OK'
307
+ }
308
+
282
309
  function formatVerboseLog (auto, record) {
283
310
  const name = (auto && (auto.alias || auto.id)) || (record && record.id) || 'automation'
284
- const result = record.result || 'skipped'
311
+ const result = resultLabel(record.result)
285
312
  const head = name + ' → ' + result + (record.reason ? ' (' + record.reason + ')' : '')
286
313
  const lines = [head]
287
314
  if (record.firedBy) lines.push(' trigger: ' + record.firedBy)
@@ -297,7 +324,7 @@ function formatVerboseLog (auto, record) {
297
324
  for (const b of record.branches || []) {
298
325
  lines.push(' ' + (b.picked ? '→ ' : ' ') + b.alias + (b.picked ? '' : ''))
299
326
  for (const c of b.conditions || []) {
300
- lines.push(' ' + markPass(c) + ' ' + (c.label || ''))
327
+ appendClauseLines(lines, c, ' ')
301
328
  }
302
329
  }
303
330
  return lines.join('\n')
@@ -392,7 +419,7 @@ async function evaluateAutomation (auto, ctx) {
392
419
  const record = {
393
420
  ts: new Date(ctx.now || Date.now()).toISOString(),
394
421
  id: auto.id,
395
- result: 'skipped',
422
+ result: 'ok',
396
423
  trigger: ctx.trigger || null,
397
424
  firedBy: triggerFiredLabel(ctx.trigger),
398
425
  conditions: [],
@@ -416,7 +443,7 @@ async function evaluateAutomation (auto, ctx) {
416
443
  record.conditions = cond.results
417
444
  if ((auto.condition || []).length && !cond.pass) {
418
445
  const failed = cond.results.find((r) => r.error)
419
- record.result = failed ? 'failure' : 'skipped'
446
+ record.result = failed ? 'failure' : 'ok'
420
447
  record.reason = failed ? failed.error : 'condition not met'
421
448
  return finishRecord(auto, record)
422
449
  }
@@ -441,6 +468,7 @@ async function evaluateAutomation (auto, ctx) {
441
468
  record.branches.push(row)
442
469
  }
443
470
  if (!picked) {
471
+ record.result = 'ok'
444
472
  record.reason = 'no matching choose branch'
445
473
  return finishRecord(auto, record)
446
474
  }
package/lib/runtime.js CHANGED
@@ -135,17 +135,24 @@ class Runtime {
135
135
  }
136
136
 
137
137
  async handlePathChange (p, value, source) {
138
+ const previous = Object.prototype.hasOwnProperty.call(this.values, p)
139
+ ? this.values[p]
140
+ : undefined
138
141
  this.setPathValue(p, value, { $source: source })
139
- await this.applyLatches(p, source)
142
+ await this.applyLatches(p, source, previous)
140
143
  await this.kickPath(p, value)
141
144
  }
142
145
 
143
- async applyLatches (p, source) {
146
+ async applyLatches (p, source, previous) {
144
147
  const fromSelf = source && String(source).includes(this.pluginId)
145
148
  if (fromSelf) return
149
+ if (previous === undefined) return
150
+ const next = this.values[p]
151
+ if (engine.sameValue(previous, next)) return
152
+ if (asSwitch(previous) !== null && asSwitch(previous) === asSwitch(next)) return
146
153
  for (const def of Object.values(this.doc.helpers)) {
147
154
  if (def.latch_on_external_put === p && def.type === 'boolean') {
148
- if (this.values[p]) await this.setHelper(def.id, true)
155
+ await this.setHelper(def.id, true)
149
156
  }
150
157
  }
151
158
  }
@@ -439,6 +446,12 @@ class Runtime {
439
446
  }
440
447
  }
441
448
 
449
+ function asSwitch (v) {
450
+ if (v === true || v === 1 || v === '1' || v === 'on' || v === 'ON') return 1
451
+ if (v === false || v === 0 || v === '0' || v === 'off' || v === 'OFF') return 0
452
+ return null
453
+ }
454
+
442
455
  function holdMs (auto) {
443
456
  for (const t of auto.trigger || []) {
444
457
  if (t.for) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-automation-plugin",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
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
@@ -73,9 +73,15 @@ async function checkLogin () {
73
73
 
74
74
  function el (id) { return document.getElementById(id) }
75
75
 
76
+ function displayResult (result) {
77
+ if (!result) return '—'
78
+ if (result === 'failure') return 'Failed'
79
+ return 'OK'
80
+ }
81
+
76
82
  function badge (result) {
77
- const r = result || '—'
78
- return '<span class="badge ' + (result || '') + '">' + r + '</span>'
83
+ const klass = !result ? '' : result === 'failure' ? 'failure' : 'ok'
84
+ return '<span class="badge ' + klass + '">' + displayResult(result) + '</span>'
79
85
  }
80
86
 
81
87
  function fmtVal (v) {
@@ -136,15 +142,18 @@ function fmtRunRecord (t) {
136
142
  function fmtTime (iso) {
137
143
  if (!iso) return ''
138
144
  try {
139
- return new Date(iso).toLocaleString('nl-NL', {
145
+ const d = new Date(iso)
146
+ if (Number.isNaN(d.getTime())) return iso
147
+ return new Intl.DateTimeFormat('nl-NL', {
148
+ timeZone: 'Europe/Amsterdam',
140
149
  year: 'numeric',
141
150
  month: '2-digit',
142
151
  day: '2-digit',
143
152
  hour: '2-digit',
144
153
  minute: '2-digit',
145
154
  second: '2-digit',
146
- hour12: false
147
- })
155
+ hourCycle: 'h23'
156
+ }).format(d)
148
157
  } catch (_) { return iso }
149
158
  }
150
159
 
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=20260915h24" />
8
+ <link rel="stylesheet" href="style.css?v=20260918ok" />
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=20260915h24"></script>
64
+ <script src="app.js?v=20260918ok"></script>
65
65
  </body>
66
66
  </html>