signalk-automation-plugin 0.0.9 → 0.0.11

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
@@ -31,8 +31,9 @@ Not Node-RED. Not a Home Assistant YAML clone. Actions are Signal K PUTs, helper
31
31
  | Allowed scripts directory | Only relative `run.file` paths under this dir |
32
32
  | Decision records to keep | Ring buffer per automation |
33
33
  | Script timeout | Seconds |
34
+ | Startup silence | Seconds after start (default 60, 0 = off) during which path triggers do not fire; values are still recorded |
34
35
 
35
- Do not paste automations into the admin form. Runtime state is `~/.signalk/plugin-config-data/signalk-automation-plugin/` (`state.json`, `live/`, `traces/`).
36
+ Do not paste automations into the admin form. Runtime state is `~/.signalk/plugin-config-data/signalk-automation-plugin/` (`state.json`, `live/`, `traces/`). `state.json` also keeps the last seen value of each trigger path (`pathValues`), so the same value republished after a restart does not re-trigger.
36
37
 
37
38
  ## Example
38
39
 
package/lib/engine.js CHANGED
@@ -74,6 +74,23 @@ function evalClause (clause, ctx) {
74
74
  clause: { not: true }
75
75
  }
76
76
  }
77
+ if (clause.triggered) {
78
+ const want = clause.triggered
79
+ const fired = ctx.trigger && ctx.trigger.path
80
+ let expected = null
81
+ if (want && typeof want === 'object') {
82
+ if (want.helper) expected = helperPath(want.helper)
83
+ else if (want.path) expected = want.path
84
+ } else if (typeof want === 'string') {
85
+ expected = want
86
+ }
87
+ return {
88
+ pass: expected != null && fired === expected,
89
+ error: expected == null ? 'triggered needs helper or path' : null,
90
+ actual: fired || null,
91
+ expected
92
+ }
93
+ }
77
94
  if (Array.isArray(clause.all)) {
78
95
  const parts = clause.all.map((c) => withLabel(c, evalClause(c, ctx)))
79
96
  return {
@@ -212,6 +229,12 @@ function fmtVal (value) {
212
229
  function clauseLabel (clause, result) {
213
230
  if (!clause || typeof clause !== 'object') return ''
214
231
  if (clause.not) return 'not ' + clauseLabel(clause.not, result)
232
+ if (clause.triggered) {
233
+ const want = clause.triggered
234
+ if (want && want.helper) return 'triggered helper ' + want.helper
235
+ if (want && want.path) return 'triggered path ' + want.path
236
+ return 'triggered ' + fmtVal(want)
237
+ }
215
238
  if (Array.isArray(clause.all)) return 'all'
216
239
  if (Array.isArray(clause.any)) return 'any'
217
240
  if (clause.zone) {
@@ -636,6 +659,20 @@ function sameValue (a, b) {
636
659
  return false
637
660
  }
638
661
 
662
+ function asSwitch (v) {
663
+ if (v === true || v === 1 || v === '1' || v === 'on' || v === 'ON') return 1
664
+ if (v === false || v === 0 || v === '0' || v === 'off' || v === 'OFF') return 0
665
+ return null
666
+ }
667
+
668
+ function valueUnchanged (previous, next) {
669
+ if (previous === undefined) return false
670
+ if (sameValue(previous, next)) return true
671
+ const a = asSwitch(previous)
672
+ const b = asSwitch(next)
673
+ return a !== null && a === b
674
+ }
675
+
639
676
  function makeRunScript (opts) {
640
677
  return function (spec) {
641
678
  return runScript({
@@ -664,6 +701,8 @@ module.exports = {
664
701
  makeRunScript,
665
702
  parseDuration,
666
703
  sameValue,
704
+ asSwitch,
705
+ valueUnchanged,
667
706
  clauseLabel,
668
707
  triggerFiredLabel,
669
708
  triggerListMatched,
package/lib/runtime.js CHANGED
@@ -17,6 +17,9 @@ class Runtime {
17
17
  this.scriptsDir = opts.scriptsDir
18
18
  this.traceCount = opts.traceCount || 20
19
19
  this.scriptTimeoutMs = (opts.scriptTimeoutSeconds || 30) * 1000
20
+ const silence = Number(opts.startupSilenceSeconds)
21
+ this.startupSilenceMs = (Number.isFinite(silence) && silence >= 0 ? silence : 0) * 1000
22
+ this.startedAt = null
20
23
  this.now = opts.now || (() => Date.now())
21
24
  this.sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
22
25
  this.putFn = opts.put
@@ -39,6 +42,8 @@ class Runtime {
39
42
 
40
43
  load () {
41
44
  this.state = store.loadState(this.dataDir)
45
+ if (!this.state.pathValues || typeof this.state.pathValues !== 'object') this.state.pathValues = {}
46
+ this.startedAt = this.now()
42
47
  const live = store.liveDir(this.dataDir)
43
48
  const from = fs.existsSync(live) ? live : this.automationsDir
44
49
  this.doc = yaml.loadYamlDir(from)
@@ -145,12 +150,53 @@ class Runtime {
145
150
  }
146
151
  }
147
152
 
153
+ triggerPaths () {
154
+ const paths = new Set()
155
+ for (const auto of this.doc.automations) {
156
+ for (const t of auto.trigger || []) {
157
+ if (t && typeof t.path === 'string') paths.add(t.path)
158
+ }
159
+ }
160
+ return paths
161
+ }
162
+
163
+ inStartupSilence () {
164
+ if (this.startedAt == null || !(this.startupSilenceMs > 0)) return false
165
+ return this.now() - this.startedAt < this.startupSilenceMs
166
+ }
167
+
168
+ rememberPathValue (p, value) {
169
+ if (!this.triggerPaths().has(p)) return
170
+ const pv = this.state.pathValues || (this.state.pathValues = {})
171
+ const has = Object.prototype.hasOwnProperty.call(pv, p)
172
+ if (has && engine.sameValue(pv[p], value)) return
173
+ if (value === undefined) return
174
+ try {
175
+ pv[p] = JSON.parse(JSON.stringify(value))
176
+ } catch (_) {
177
+ return
178
+ }
179
+ store.saveState(this.dataDir, this.state)
180
+ }
181
+
148
182
  async handlePathChange (p, value, source) {
149
- const previous = Object.prototype.hasOwnProperty.call(this.values, p)
183
+ let previous = Object.prototype.hasOwnProperty.call(this.values, p)
150
184
  ? this.values[p]
151
185
  : undefined
186
+ const inMemory = previous !== undefined
187
+ const pv = this.state.pathValues || {}
188
+ if (!inMemory && Object.prototype.hasOwnProperty.call(pv, p)) {
189
+ // Value remembered from before a restart.
190
+ previous = pv[p]
191
+ }
152
192
  this.setPathValue(p, value, { $source: source })
153
- await this.applyLatches(p, source, previous)
193
+ this.rememberPathValue(p, value)
194
+ await this.applyLatches(p, source, inMemory ? previous : undefined)
195
+ if (engine.valueUnchanged(previous, value)) return
196
+ if (this.inStartupSilence()) {
197
+ this.log.debug('startup silence: not triggering on ' + p)
198
+ return
199
+ }
154
200
  await this.kickPath(p, value)
155
201
  }
156
202
 
@@ -159,8 +205,7 @@ class Runtime {
159
205
  if (fromSelf) return
160
206
  if (previous === undefined) return
161
207
  const next = this.values[p]
162
- if (engine.sameValue(previous, next)) return
163
- if (asSwitch(previous) !== null && asSwitch(previous) === asSwitch(next)) return
208
+ if (engine.valueUnchanged(previous, next)) return
164
209
  for (const def of Object.values(this.doc.helpers)) {
165
210
  if (def.latch_on_external_put === p && def.type === 'boolean') {
166
211
  await this.setHelper(def.id, true)
@@ -507,12 +552,6 @@ function newAbort () {
507
552
  return abort
508
553
  }
509
554
 
510
- function asSwitch (v) {
511
- if (v === true || v === 1 || v === '1' || v === 'on' || v === 'ON') return 1
512
- if (v === false || v === 0 || v === '0' || v === 'off' || v === 'OFF') return 0
513
- return null
514
- }
515
-
516
555
  function holdMs (auto) {
517
556
  for (const t of auto.trigger || []) {
518
557
  if (t.for) {
package/lib/store.js CHANGED
@@ -9,7 +9,8 @@ function emptyState () {
9
9
  enabled: {},
10
10
  verbose: {},
11
11
  activeSha: null,
12
- lastRun: {}
12
+ lastRun: {},
13
+ pathValues: {}
13
14
  }
14
15
  }
15
16
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-automation-plugin",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
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
@@ -84,6 +84,13 @@ module.exports = function (app) {
84
84
  title: 'Script timeout (seconds)',
85
85
  default: 30,
86
86
  minimum: 1
87
+ },
88
+ startupSilence: {
89
+ type: 'number',
90
+ title: 'Startup silence (seconds)',
91
+ description: 'After start, path triggers do not fire for this many seconds (values are still recorded). 0 = off.',
92
+ default: 60,
93
+ minimum: 0
87
94
  }
88
95
  }
89
96
  }
@@ -424,6 +431,7 @@ module.exports = function (app) {
424
431
  scriptsDir: options.scriptsDir || '',
425
432
  traceCount: options.traceCount,
426
433
  scriptTimeoutSeconds: options.scriptTimeoutSeconds,
434
+ startupSilenceSeconds: options.startupSilence == null ? 60 : options.startupSilence,
427
435
  put: putPath,
428
436
  notify,
429
437
  onHelper: (id, value) => emitHelper(id, value),