signalk-automation-plugin 0.0.1 → 0.0.3

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/runtime.js CHANGED
@@ -31,6 +31,7 @@ class Runtime {
31
31
  this.pendingSince = {}
32
32
  this.lastDebounce = {}
33
33
  this.lastCronMinute = ''
34
+ this.lastQuantized = {}
34
35
  this.unsubscribes = []
35
36
  }
36
37
 
@@ -52,10 +53,11 @@ class Runtime {
52
53
  }
53
54
 
54
55
  isEnabled (auto) {
55
- if (Object.prototype.hasOwnProperty.call(this.state.enabled, auto.id)) {
56
- return !!this.state.enabled[auto.id]
57
- }
58
- return auto.enabled !== false
56
+ return !!this.state.enabled[auto.id]
57
+ }
58
+
59
+ isVerbose (auto) {
60
+ return !!this.state.verbose[auto.id]
59
61
  }
60
62
 
61
63
  setEnabled (id, enabled) {
@@ -63,19 +65,43 @@ class Runtime {
63
65
  store.saveState(this.dataDir, this.state)
64
66
  }
65
67
 
66
- setHelper (id, value) {
68
+ setVerbose (id, verbose) {
69
+ this.state.verbose[id] = !!verbose
70
+ store.saveState(this.dataDir, this.state)
71
+ }
72
+
73
+ async setHelper (id, value, opts) {
67
74
  const def = this.doc.helpers[id]
68
75
  if (!def) throw new Error('Unknown helper: ' + id)
76
+ const previous = Object.prototype.hasOwnProperty.call(this.helperValues, id)
77
+ ? this.helperValues[id]
78
+ : undefined
69
79
  const coerced = helpers.coerce(def, value)
70
80
  this.helperValues[id] = coerced
71
81
  this.values[helpers.helperPath(id)] = coerced
72
82
  this.persistHelpers()
83
+ const changed = !engine.sameValue(previous, coerced)
84
+ if (changed) {
85
+ this.recordSwitch({
86
+ kind: 'helper',
87
+ path: id,
88
+ from: previous,
89
+ to: coerced,
90
+ changed: true
91
+ })
92
+ if (!opts || opts.kick !== false) {
93
+ await this.kickPath(helpers.helperPath(id), coerced)
94
+ }
95
+ }
73
96
  return coerced
74
97
  }
75
98
 
76
- resetHelper (id) {
99
+ async resetHelper (id) {
77
100
  const def = this.doc.helpers[id]
78
101
  if (!def) throw new Error('Unknown helper: ' + id)
102
+ const previous = Object.prototype.hasOwnProperty.call(this.helperValues, id)
103
+ ? this.helperValues[id]
104
+ : undefined
79
105
  if (def.default === undefined) {
80
106
  delete this.helperValues[id]
81
107
  delete this.values[helpers.helperPath(id)]
@@ -84,9 +110,23 @@ class Runtime {
84
110
  this.values[helpers.helperPath(id)] = this.helperValues[id]
85
111
  }
86
112
  this.persistHelpers()
113
+ const next = Object.prototype.hasOwnProperty.call(this.helperValues, id)
114
+ ? this.helperValues[id]
115
+ : undefined
116
+ if (!engine.sameValue(previous, next)) {
117
+ await this.kickPath(helpers.helperPath(id), next)
118
+ }
87
119
  return this.helperValues[id]
88
120
  }
89
121
 
122
+ async kickPath (p, value) {
123
+ for (const auto of this.doc.automations) {
124
+ if (!engine.triggerMatchesPath(auto, p)) continue
125
+ if (engine.quantizedUnchanged(this.lastQuantized, auto, p, value)) continue
126
+ await this.maybeRun(auto, { path: p, value })
127
+ }
128
+ }
129
+
90
130
  setPathValue (p, value, meta) {
91
131
  this.values[p] = value
92
132
  if (meta && meta.$source) {
@@ -96,19 +136,16 @@ class Runtime {
96
136
 
97
137
  async handlePathChange (p, value, source) {
98
138
  this.setPathValue(p, value, { $source: source })
99
- this.applyLatches(p, source)
100
- for (const auto of this.doc.automations) {
101
- if (!engine.triggerMatchesPath(auto, p)) continue
102
- await this.maybeRun(auto, { path: p, value })
103
- }
139
+ await this.applyLatches(p, source)
140
+ await this.kickPath(p, value)
104
141
  }
105
142
 
106
- applyLatches (p, source) {
143
+ async applyLatches (p, source) {
107
144
  const fromSelf = source && String(source).includes(this.pluginId)
108
145
  if (fromSelf) return
109
146
  for (const def of Object.values(this.doc.helpers)) {
110
147
  if (def.latch_on_external_put === p && def.type === 'boolean') {
111
- if (this.values[p]) this.setHelper(def.id, true)
148
+ if (this.values[p]) await this.setHelper(def.id, true)
112
149
  }
113
150
  }
114
151
  }
@@ -129,8 +166,8 @@ class Runtime {
129
166
  async maybeRun (auto, trigger) {
130
167
  if (!this.isEnabled(auto)) return
131
168
  const ctx = this.makeCtx(trigger)
132
- const trig = await engine.evalList(auto.trigger || [], ctx)
133
- const matched = !(auto.trigger || []).length || trig.pass
169
+ const trig = await engine.evalListAll(auto.trigger || [], ctx)
170
+ const matched = engine.triggerListMatched(auto.trigger, trig.results)
134
171
 
135
172
  if (!matched) {
136
173
  this.lastTruthy[auto.id] = false
@@ -145,7 +182,7 @@ class Runtime {
145
182
  }
146
183
 
147
184
  const prev = !!this.lastTruthy[auto.id]
148
- const edge = auto.edge || 'rising'
185
+ const edge = auto.edge || 'every'
149
186
  if (edge === 'rising' && prev) return
150
187
  if (edge === 'falling') return
151
188
  this.lastTruthy[auto.id] = true
@@ -156,26 +193,135 @@ class Runtime {
156
193
 
157
194
  this.lastDebounce[auto.id] = this.now()
158
195
  const record = await engine.evaluateAutomation(auto, this.makeCtx(trigger))
196
+ this.finishRun(auto, record, { forceTrace: false })
197
+ return record
198
+ }
199
+
200
+ keepTrace (auto, record, extra) {
201
+ extra = extra || {}
202
+ if (extra.forceTrace) return true
203
+ if (this.isVerbose(auto)) return true
204
+ if (record.result === 'failure') return true
205
+ if (record.result === 'ok') {
206
+ return (record.actions || []).some((a) => a.changed)
207
+ }
208
+ return false
209
+ }
210
+
211
+ finishRun (auto, record, extra) {
212
+ extra = extra || {}
159
213
  this.state.lastRun[auto.id] = {
160
214
  ts: record.ts,
161
215
  result: record.result,
162
216
  reason: record.reason || null
163
217
  }
164
218
  store.saveState(this.dataDir, this.state)
165
- store.appendTrace(this.dataDir, auto.id, record, this.traceCount)
219
+ if (this.keepTrace(auto, record, extra)) {
220
+ store.appendTrace(this.dataDir, auto.id, record, this.traceCount)
221
+ }
222
+ this.noteSwitches(auto, record)
223
+ if (this.isVerbose(auto) && record.verboseLog) this.info(record.verboseLog)
224
+ }
225
+
226
+ scheduleOnly (auto) {
227
+ const trig = auto.trigger || []
228
+ return trig.length > 0 && trig.every((t) => t.schedule)
229
+ }
230
+
231
+ async evaluateOnStart () {
232
+ this.seedFromSelf()
233
+ for (const auto of this.doc.automations) {
234
+ if (!this.isEnabled(auto)) continue
235
+ if (this.scheduleOnly(auto)) continue
236
+ await this.maybeRun(auto, { start: true })
237
+ }
238
+ }
239
+
240
+ seedFromSelf () {
241
+ if (!this.app || typeof this.app.getSelfPath !== 'function') return
242
+ for (const p of engine.collectPaths(this.doc)) {
243
+ if (p.startsWith('automations.helpers.')) continue
244
+ try {
245
+ const raw = this.app.getSelfPath(p)
246
+ const value =
247
+ raw && typeof raw === 'object' && Object.prototype.hasOwnProperty.call(raw, 'value')
248
+ ? raw.value
249
+ : raw
250
+ if (value !== undefined) this.values[p] = value
251
+ } catch (_) {}
252
+ }
253
+ }
254
+
255
+ async runNow (id) {
256
+ const auto = this.doc.automations.find((a) => a.id === id)
257
+ if (!auto) throw new Error('Unknown automation: ' + id)
258
+ this.seedFromSelf()
259
+ const forced = Object.assign({}, auto, { trigger: [] })
260
+ const record = await engine.evaluateAutomation(
261
+ forced,
262
+ this.makeCtx({ manual: true }, { skipDelay: true, skipUnchanged: false })
263
+ )
264
+ record.id = auto.id
265
+ record.manual = true
266
+ this.finishRun(auto, record, { forceTrace: true })
267
+ if (!this.isVerbose(auto)) {
268
+ this.info('Run ' + (auto.alias || auto.id) + ' → ' + record.result + (record.reason ? ' (' + record.reason + ')' : ''))
269
+ }
166
270
  return record
167
271
  }
168
272
 
169
- makeCtx (trigger) {
273
+ info (msg) {
274
+ if (this.log && typeof this.log.info === 'function') this.log.info(msg)
275
+ else if (this.log && typeof this.log.debug === 'function') this.log.debug(msg)
276
+ }
277
+
278
+ formatSwitch (row) {
279
+ const who = [row.automation, row.branch].filter(Boolean).join(' / ')
280
+ const kind = row.kind === 'helper' ? 'helper ' + row.path : 'PUT ' + row.path
281
+ const line = (who ? who + ' · ' : '') + kind + ' ' + fmtVal(row.from) + ' → ' + fmtVal(row.to)
282
+ return row.changed === false ? line + ' (unchanged)' : line
283
+ }
284
+
285
+ recordSwitch (entry) {
286
+ const row = Object.assign({ ts: new Date(this.now()).toISOString() }, entry)
287
+ if (row.changed === false) {
288
+ if (this.log && typeof this.log.debug === 'function') this.log.debug(this.formatSwitch(row))
289
+ return row
290
+ }
291
+ store.appendSwitchLog(this.dataDir, row, 50)
292
+ this.info(this.formatSwitch(row))
293
+ return row
294
+ }
295
+
296
+ noteSwitches (auto, record) {
297
+ for (const a of record.actions || []) {
298
+ if (a.type !== 'put' || !a.ok) continue
299
+ this.recordSwitch({
300
+ kind: 'put',
301
+ automation: auto.alias || auto.id,
302
+ id: auto.id,
303
+ branch: record.choose || null,
304
+ path: a.path,
305
+ from: a.previous,
306
+ to: a.value,
307
+ changed: a.changed !== false
308
+ })
309
+ }
310
+ }
311
+
312
+ makeCtx (trigger, extra) {
313
+ extra = extra || {}
170
314
  return {
171
315
  values: this.values,
172
316
  zones: this.doc.zones,
173
317
  enabled: true,
174
318
  trigger,
175
319
  now: this.now(),
320
+ skipDelay: !!extra.skipDelay,
321
+ skipUnchanged: extra.skipUnchanged !== false,
176
322
  put: (p, value) => this.put(p, value),
177
323
  notify: (p, state, message) => this.notify(p, state, message),
178
- setHelper: (id, value) => this.setHelper(id, value),
324
+ setHelper: (id, value) => this.setHelper(id, value, { kick: false }),
179
325
  sleep: this.sleep,
180
326
  runScript: engine.makeRunScript({
181
327
  scriptsDir: this.scriptsDir,
@@ -185,8 +331,8 @@ class Runtime {
185
331
  }
186
332
 
187
333
  async put (p, value) {
334
+ if (this.putFn) await this.putFn(p, value)
188
335
  this.values[p] = value
189
- if (this.putFn) return this.putFn(p, value)
190
336
  }
191
337
 
192
338
  async notify (p, state, message) {
@@ -208,6 +354,7 @@ class Runtime {
208
354
  on_start: def.on_start,
209
355
  default: def.default,
210
356
  options: def.options,
357
+ yaml: yaml.snippetHelper(this.doc, def.id),
211
358
  value: Object.prototype.hasOwnProperty.call(this.helperValues, def.id)
212
359
  ? this.helperValues[def.id]
213
360
  : null,
@@ -217,10 +364,20 @@ class Runtime {
217
364
  id: auto.id,
218
365
  alias: auto.alias,
219
366
  enabled: this.isEnabled(auto),
220
- yamlEnabled: auto.enabled !== false,
367
+ verbose: this.isVerbose(auto),
368
+ yaml: yaml.snippetAutomation(this.doc, auto.id),
221
369
  lastRun: this.state.lastRun[auto.id] || null
222
370
  })),
223
- zones: Object.values(this.doc.zones),
371
+ zones: Object.values(this.doc.zones).map((z) => ({
372
+ id: z.id,
373
+ name: z.name,
374
+ lat: z.lat,
375
+ lon: z.lon,
376
+ radius: z.radius,
377
+ yaml: yaml.snippetHelper(this.doc, z.id)
378
+ })),
379
+ switches: store.loadSwitchLog(this.dataDir),
380
+ headSha: git.currentSha(this.automationsDir),
224
381
  commits: git.isGitRepo(this.automationsDir)
225
382
  ? git.listCommits(this.automationsDir, 40)
226
383
  : []
@@ -231,11 +388,39 @@ class Runtime {
231
388
  return store.loadTraces(this.dataDir, id)
232
389
  }
233
390
 
391
+ diffCommit (sha) {
392
+ const to = sha || ''
393
+ if (!to) throw new Error('Commit sha is required')
394
+ const from = this.state.activeSha || git.currentSha(this.automationsDir)
395
+ const result = git.diffRange(this.automationsDir, from, to)
396
+ const commits = git.listCommits(this.automationsDir, 80)
397
+ const found = commits.find((c) => c.sha === result.to) || {}
398
+ return {
399
+ from: result.from,
400
+ to: result.to,
401
+ diff: result.diff,
402
+ subject: found.subject || '',
403
+ current: !result.from || result.from === result.to
404
+ }
405
+ }
406
+
234
407
  activateCommit (sha) {
235
408
  const dest = store.liveDir(this.dataDir)
236
409
  const incoming = dest + '.incoming'
237
410
  if (fs.existsSync(incoming)) fs.rmSync(incoming, { recursive: true, force: true })
238
411
  git.materializeCommit(this.automationsDir, sha, incoming)
412
+ return this.installIncoming(incoming, dest, sha)
413
+ }
414
+
415
+ reloadWorkingTree () {
416
+ const dest = store.liveDir(this.dataDir)
417
+ const incoming = dest + '.incoming'
418
+ if (fs.existsSync(incoming)) fs.rmSync(incoming, { recursive: true, force: true })
419
+ store.copyYamlFiles(this.automationsDir, incoming)
420
+ return this.installIncoming(incoming, dest, git.currentSha(this.automationsDir))
421
+ }
422
+
423
+ installIncoming (incoming, dest, sha) {
239
424
  const next = yaml.loadYamlDir(incoming)
240
425
  if (next.errors.length) {
241
426
  fs.rmSync(incoming, { recursive: true, force: true })
@@ -247,7 +432,7 @@ class Runtime {
247
432
  if (fs.existsSync(dest)) fs.renameSync(dest, bak)
248
433
  fs.renameSync(incoming, dest)
249
434
  if (fs.existsSync(bak)) fs.rmSync(bak, { recursive: true, force: true })
250
- this.state.activeSha = sha
435
+ this.state.activeSha = sha || null
251
436
  store.saveState(this.dataDir, this.state)
252
437
  this.load()
253
438
  return this.snapshot()
@@ -267,4 +452,13 @@ function holdMs (auto) {
267
452
  return 0
268
453
  }
269
454
 
455
+ function fmtVal (v) {
456
+ if (v === undefined) return 'unset'
457
+ try {
458
+ return JSON.stringify(v)
459
+ } catch (_) {
460
+ return String(v)
461
+ }
462
+ }
463
+
270
464
  module.exports = { Runtime }
package/lib/store.js CHANGED
@@ -7,6 +7,7 @@ function emptyState () {
7
7
  return {
8
8
  helpers: {},
9
9
  enabled: {},
10
+ verbose: {},
10
11
  activeSha: null,
11
12
  lastRun: {}
12
13
  }
@@ -60,6 +61,43 @@ function liveDir (dir) {
60
61
  return path.join(dir, 'live')
61
62
  }
62
63
 
64
+ function copyYamlFiles (fromDir, toDir) {
65
+ fs.mkdirSync(toDir, { recursive: true })
66
+ if (!fromDir || !fs.existsSync(fromDir)) {
67
+ throw new Error('automationsDir is missing')
68
+ }
69
+ const names = fs.readdirSync(fromDir).filter((f) => /\.ya?ml$/i.test(f))
70
+ for (const name of names) {
71
+ fs.copyFileSync(path.join(fromDir, name), path.join(toDir, name))
72
+ }
73
+ return names
74
+ }
75
+
76
+ function switchLogPath (dir) {
77
+ return path.join(dir, 'switch-log.json')
78
+ }
79
+
80
+ function loadSwitchLog (dir) {
81
+ try {
82
+ const data = JSON.parse(fs.readFileSync(switchLogPath(dir), 'utf8'))
83
+ return Array.isArray(data) ? data : []
84
+ } catch (_) {
85
+ return []
86
+ }
87
+ }
88
+
89
+ function appendSwitchLog (dir, entry, max) {
90
+ const cap = max > 0 ? max : 50
91
+ const list = loadSwitchLog(dir)
92
+ list.unshift(entry)
93
+ while (list.length > cap) list.pop()
94
+ fs.mkdirSync(dir, { recursive: true })
95
+ const tmp = switchLogPath(dir) + '.tmp'
96
+ fs.writeFileSync(tmp, JSON.stringify(list, null, 2) + '\n')
97
+ fs.renameSync(tmp, switchLogPath(dir))
98
+ return list
99
+ }
100
+
63
101
  function safeId (id) {
64
102
  return String(id || 'unknown').replace(/[^A-Za-z0-9._-]+/g, '_')
65
103
  }
@@ -71,5 +109,8 @@ module.exports = {
71
109
  loadTraces,
72
110
  appendTrace,
73
111
  liveDir,
74
- tracesPath
112
+ tracesPath,
113
+ copyYamlFiles,
114
+ loadSwitchLog,
115
+ appendSwitchLog
75
116
  }
package/lib/yaml.js CHANGED
@@ -167,14 +167,24 @@ function mergeAutomations (result, list, file) {
167
167
  id: String(raw.id),
168
168
  file,
169
169
  alias: raw.alias || raw.id,
170
- enabled: raw.enabled !== false,
171
170
  trigger: asArray(raw.trigger),
172
171
  condition: asArray(raw.condition),
173
172
  action: asArray(raw.action),
174
173
  choose: Array.isArray(raw.choose) ? raw.choose : [],
175
174
  debounceMs: raw.debounceMs || 0,
176
- edge: raw.edge || 'rising'
175
+ edge: raw.edge || 'every'
177
176
  })
177
+ for (const t of asArray(raw.trigger)) {
178
+ if (!t || t.round == null) continue
179
+ const n = Number(t.round)
180
+ if (!Number.isFinite(n) || n <= 0) {
181
+ result.errors.push({
182
+ file,
183
+ message: 'Automation ' + raw.id + ': round must be a positive number'
184
+ })
185
+ }
186
+ }
187
+ if (raw.zones) mergeZones(result, raw.zones, file)
178
188
  }
179
189
  }
180
190
 
@@ -194,11 +204,83 @@ function combinedYaml (doc) {
194
204
  return parts.join('\n\n---\n\n')
195
205
  }
196
206
 
207
+ function escapeRegExp (s) {
208
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
209
+ }
210
+
211
+ function extractIndentedBlock (text, startLine, baseIndent) {
212
+ const lines = text.split('\n')
213
+ let end = startLine + 1
214
+ while (end < lines.length) {
215
+ const line = lines[end]
216
+ if (line.trim() === '') {
217
+ end++
218
+ continue
219
+ }
220
+ const indent = (line.match(/^[ \t]*/) || [''])[0]
221
+ if (indent.length <= baseIndent.length) break
222
+ end++
223
+ }
224
+ while (end > startLine + 1 && lines[end - 1].trim() === '') end--
225
+ return lines.slice(startLine, end).join('\n').replace(/\s+$/, '') + '\n'
226
+ }
227
+
228
+ function extractListItemById (text, id) {
229
+ if (!text || !id) return null
230
+ const lines = text.split('\n')
231
+ const idRe = new RegExp('^- id:\\s*["\']?' + escapeRegExp(id) + '["\']?\\s*$')
232
+ for (let i = 0; i < lines.length; i++) {
233
+ const indent = (lines[i].match(/^[ \t]*/) || [''])[0]
234
+ const rest = lines[i].slice(indent.length)
235
+ if (idRe.test(rest)) return extractIndentedBlock(text, i, indent)
236
+ }
237
+ return null
238
+ }
239
+
240
+ function extractMappingEntry (text, id) {
241
+ if (!text || !id) return null
242
+ const lines = text.split('\n')
243
+ const keyRe = new RegExp('^' + escapeRegExp(id) + ':\\s*(.*)$')
244
+ for (let i = 0; i < lines.length; i++) {
245
+ const indent = (lines[i].match(/^[ \t]*/) || [''])[0]
246
+ const rest = lines[i].slice(indent.length)
247
+ const m = rest.match(keyRe)
248
+ if (!m) continue
249
+ if (m[1] && m[1].trim() && !m[1].trim().startsWith('#') && !m[1].trim().startsWith('|') && !m[1].trim().startsWith('>')) {
250
+ return lines[i].replace(/\s+$/, '') + '\n'
251
+ }
252
+ return extractIndentedBlock(text, i, indent)
253
+ }
254
+ return null
255
+ }
256
+
257
+ function snippetAutomation (doc, id) {
258
+ for (const file of doc.files || []) {
259
+ const text = doc.rawText && doc.rawText[file]
260
+ const block = extractListItemById(text, id)
261
+ if (block) return block
262
+ }
263
+ return ''
264
+ }
265
+
266
+ function snippetHelper (doc, id) {
267
+ for (const file of doc.files || []) {
268
+ const text = doc.rawText && doc.rawText[file]
269
+ const block = extractMappingEntry(text, id)
270
+ if (block) return block
271
+ }
272
+ return ''
273
+ }
274
+
197
275
  module.exports = {
198
276
  ON_START,
199
277
  HELPER_TYPES,
200
278
  loadYamlDir,
201
279
  loadYamlText,
202
280
  emptyDoc,
203
- combinedYaml
281
+ combinedYaml,
282
+ extractListItemById,
283
+ extractMappingEntry,
284
+ snippetAutomation,
285
+ snippetHelper
204
286
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-automation-plugin",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Home Assistant-like YAML automations for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "keywords": [
@@ -20,6 +20,7 @@
20
20
  },
21
21
  "homepage": "https://github.com/htool/signalk-automation-plugin#readme",
22
22
  "signalk": {
23
+ "appIcon": "icon.png",
23
24
  "displayName": "Automations"
24
25
  },
25
26
  "signalk-plugin-enabled-by-default": false,