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/README.md +17 -9
- package/examples/automations.yaml +49 -44
- package/lib/engine.js +240 -21
- package/lib/geo.js +17 -9
- package/lib/git.js +33 -1
- package/lib/runtime.js +217 -23
- package/lib/store.js +42 -1
- package/lib/yaml.js +85 -3
- package/package.json +2 -1
- package/plugin/index.js +205 -52
- package/public/app.js +504 -73
- package/public/icon.png +0 -0
- package/public/index.html +48 -21
- package/public/style.css +195 -20
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
133
|
-
const matched =
|
|
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 || '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 || '
|
|
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.
|
|
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,
|