signalk-automation-plugin 0.0.1

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/helpers.js ADDED
@@ -0,0 +1,60 @@
1
+ 'use strict'
2
+
3
+ function coerce (def, value) {
4
+ if (!def) return value
5
+ if (def.type === 'boolean') {
6
+ if (value === true || value === 'true' || value === 1 || value === '1') return true
7
+ if (value === false || value === 'false' || value === 0 || value === '0') return false
8
+ return Boolean(value)
9
+ }
10
+ if (def.type === 'number') {
11
+ const n = Number(value)
12
+ if (!Number.isFinite(n)) throw new Error('Helper ' + def.id + ' expects a number')
13
+ return n
14
+ }
15
+ const s = value == null ? '' : String(value)
16
+ if (def.type === 'select' && def.options.length && !def.options.includes(s)) {
17
+ throw new Error('Helper ' + def.id + ' value not in options')
18
+ }
19
+ return s
20
+ }
21
+
22
+ function initHelpers (defs, persisted) {
23
+ const values = {}
24
+ const saved = persisted && typeof persisted === 'object' ? persisted : {}
25
+ for (const [id, def] of Object.entries(defs || {})) {
26
+ const mode = def.on_start || 'none'
27
+ if (mode === 'restore') {
28
+ if (Object.prototype.hasOwnProperty.call(saved, id)) {
29
+ values[id] = coerce(def, saved[id])
30
+ } else if (def.default !== undefined) {
31
+ values[id] = coerce(def, def.default)
32
+ }
33
+ } else if (mode === 'default') {
34
+ if (def.default !== undefined) values[id] = coerce(def, def.default)
35
+ }
36
+ // none: leave unset
37
+ }
38
+ return values
39
+ }
40
+
41
+ function persistableHelpers (defs, values) {
42
+ const out = {}
43
+ for (const [id, def] of Object.entries(defs || {})) {
44
+ if (def.on_start !== 'restore') continue
45
+ if (!Object.prototype.hasOwnProperty.call(values, id)) continue
46
+ out[id] = values[id]
47
+ }
48
+ return out
49
+ }
50
+
51
+ function helperPath (id) {
52
+ return 'automations.helpers.' + id
53
+ }
54
+
55
+ module.exports = {
56
+ coerce,
57
+ initHelpers,
58
+ persistableHelpers,
59
+ helperPath
60
+ }
package/lib/runtime.js ADDED
@@ -0,0 +1,270 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const yaml = require('./yaml')
6
+ const helpers = require('./helpers')
7
+ const store = require('./store')
8
+ const git = require('./git')
9
+ const engine = require('./engine')
10
+
11
+ class Runtime {
12
+ constructor (opts) {
13
+ this.app = opts.app
14
+ this.pluginId = opts.pluginId
15
+ this.dataDir = opts.dataDir
16
+ this.automationsDir = opts.automationsDir
17
+ this.scriptsDir = opts.scriptsDir
18
+ this.traceCount = opts.traceCount || 20
19
+ this.scriptTimeoutMs = (opts.scriptTimeoutSeconds || 30) * 1000
20
+ this.now = opts.now || (() => Date.now())
21
+ this.sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
22
+ this.putFn = opts.put
23
+ this.notifyFn = opts.notify
24
+ this.log = opts.log || { debug () {}, error () {} }
25
+
26
+ this.doc = yaml.emptyDoc()
27
+ this.state = store.emptyState()
28
+ this.values = {}
29
+ this.helperValues = {}
30
+ this.lastTruthy = {}
31
+ this.pendingSince = {}
32
+ this.lastDebounce = {}
33
+ this.lastCronMinute = ''
34
+ this.unsubscribes = []
35
+ }
36
+
37
+ load () {
38
+ this.state = store.loadState(this.dataDir)
39
+ const live = store.liveDir(this.dataDir)
40
+ const from = fs.existsSync(live) ? live : this.automationsDir
41
+ this.doc = yaml.loadYamlDir(from)
42
+ this.helperValues = helpers.initHelpers(this.doc.helpers, this.state.helpers)
43
+ this.persistHelpers()
44
+ for (const [id, value] of Object.entries(this.helperValues)) {
45
+ this.values[helpers.helperPath(id)] = value
46
+ }
47
+ }
48
+
49
+ persistHelpers () {
50
+ this.state.helpers = helpers.persistableHelpers(this.doc.helpers, this.helperValues)
51
+ store.saveState(this.dataDir, this.state)
52
+ }
53
+
54
+ 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
59
+ }
60
+
61
+ setEnabled (id, enabled) {
62
+ this.state.enabled[id] = !!enabled
63
+ store.saveState(this.dataDir, this.state)
64
+ }
65
+
66
+ setHelper (id, value) {
67
+ const def = this.doc.helpers[id]
68
+ if (!def) throw new Error('Unknown helper: ' + id)
69
+ const coerced = helpers.coerce(def, value)
70
+ this.helperValues[id] = coerced
71
+ this.values[helpers.helperPath(id)] = coerced
72
+ this.persistHelpers()
73
+ return coerced
74
+ }
75
+
76
+ resetHelper (id) {
77
+ const def = this.doc.helpers[id]
78
+ if (!def) throw new Error('Unknown helper: ' + id)
79
+ if (def.default === undefined) {
80
+ delete this.helperValues[id]
81
+ delete this.values[helpers.helperPath(id)]
82
+ } else {
83
+ this.helperValues[id] = helpers.coerce(def, def.default)
84
+ this.values[helpers.helperPath(id)] = this.helperValues[id]
85
+ }
86
+ this.persistHelpers()
87
+ return this.helperValues[id]
88
+ }
89
+
90
+ setPathValue (p, value, meta) {
91
+ this.values[p] = value
92
+ if (meta && meta.$source) {
93
+ this.values[p + '.$source'] = meta.$source
94
+ }
95
+ }
96
+
97
+ async handlePathChange (p, value, source) {
98
+ 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
+ }
104
+ }
105
+
106
+ applyLatches (p, source) {
107
+ const fromSelf = source && String(source).includes(this.pluginId)
108
+ if (fromSelf) return
109
+ for (const def of Object.values(this.doc.helpers)) {
110
+ if (def.latch_on_external_put === p && def.type === 'boolean') {
111
+ if (this.values[p]) this.setHelper(def.id, true)
112
+ }
113
+ }
114
+ }
115
+
116
+ async tickSchedule (date) {
117
+ const d = date || new Date(this.now())
118
+ const key = d.getFullYear() + '-' + d.getMonth() + '-' + d.getDate() + '-' + d.getHours() + '-' + d.getMinutes()
119
+ if (key === this.lastCronMinute) return
120
+ this.lastCronMinute = key
121
+ for (const auto of this.doc.automations) {
122
+ const schedules = (auto.trigger || []).filter((t) => t.schedule)
123
+ if (!schedules.length) continue
124
+ const hit = schedules.some((t) => engine.cronMatch(t.schedule, d))
125
+ if (hit) await this.maybeRun(auto, { schedule: schedules[0].schedule })
126
+ }
127
+ }
128
+
129
+ async maybeRun (auto, trigger) {
130
+ if (!this.isEnabled(auto)) return
131
+ const ctx = this.makeCtx(trigger)
132
+ const trig = await engine.evalList(auto.trigger || [], ctx)
133
+ const matched = !(auto.trigger || []).length || trig.pass
134
+
135
+ if (!matched) {
136
+ this.lastTruthy[auto.id] = false
137
+ delete this.pendingSince[auto.id]
138
+ return
139
+ }
140
+
141
+ const hold = holdMs(auto)
142
+ if (hold > 0) {
143
+ if (!this.pendingSince[auto.id]) this.pendingSince[auto.id] = this.now()
144
+ if (this.now() - this.pendingSince[auto.id] < hold) return
145
+ }
146
+
147
+ const prev = !!this.lastTruthy[auto.id]
148
+ const edge = auto.edge || 'rising'
149
+ if (edge === 'rising' && prev) return
150
+ if (edge === 'falling') return
151
+ this.lastTruthy[auto.id] = true
152
+
153
+ if (auto.debounceMs && this.lastDebounce[auto.id] && this.now() - this.lastDebounce[auto.id] < auto.debounceMs) {
154
+ return
155
+ }
156
+
157
+ this.lastDebounce[auto.id] = this.now()
158
+ const record = await engine.evaluateAutomation(auto, this.makeCtx(trigger))
159
+ this.state.lastRun[auto.id] = {
160
+ ts: record.ts,
161
+ result: record.result,
162
+ reason: record.reason || null
163
+ }
164
+ store.saveState(this.dataDir, this.state)
165
+ store.appendTrace(this.dataDir, auto.id, record, this.traceCount)
166
+ return record
167
+ }
168
+
169
+ makeCtx (trigger) {
170
+ return {
171
+ values: this.values,
172
+ zones: this.doc.zones,
173
+ enabled: true,
174
+ trigger,
175
+ now: this.now(),
176
+ put: (p, value) => this.put(p, value),
177
+ notify: (p, state, message) => this.notify(p, state, message),
178
+ setHelper: (id, value) => this.setHelper(id, value),
179
+ sleep: this.sleep,
180
+ runScript: engine.makeRunScript({
181
+ scriptsDir: this.scriptsDir,
182
+ timeoutMs: this.scriptTimeoutMs
183
+ })
184
+ }
185
+ }
186
+
187
+ async put (p, value) {
188
+ this.values[p] = value
189
+ if (this.putFn) return this.putFn(p, value)
190
+ }
191
+
192
+ async notify (p, state, message) {
193
+ if (this.notifyFn) return this.notifyFn(p, state, message)
194
+ }
195
+
196
+ snapshot () {
197
+ return {
198
+ started: true,
199
+ activeSha: this.state.activeSha,
200
+ automationsDir: this.automationsDir || null,
201
+ errors: this.doc.errors,
202
+ files: this.doc.files,
203
+ yaml: yaml.combinedYaml(this.doc),
204
+ helpers: Object.values(this.doc.helpers).map((def) => ({
205
+ id: def.id,
206
+ name: def.name,
207
+ type: def.type,
208
+ on_start: def.on_start,
209
+ default: def.default,
210
+ options: def.options,
211
+ value: Object.prototype.hasOwnProperty.call(this.helperValues, def.id)
212
+ ? this.helperValues[def.id]
213
+ : null,
214
+ set: Object.prototype.hasOwnProperty.call(this.helperValues, def.id)
215
+ })),
216
+ automations: this.doc.automations.map((auto) => ({
217
+ id: auto.id,
218
+ alias: auto.alias,
219
+ enabled: this.isEnabled(auto),
220
+ yamlEnabled: auto.enabled !== false,
221
+ lastRun: this.state.lastRun[auto.id] || null
222
+ })),
223
+ zones: Object.values(this.doc.zones),
224
+ commits: git.isGitRepo(this.automationsDir)
225
+ ? git.listCommits(this.automationsDir, 40)
226
+ : []
227
+ }
228
+ }
229
+
230
+ traces (id) {
231
+ return store.loadTraces(this.dataDir, id)
232
+ }
233
+
234
+ activateCommit (sha) {
235
+ const dest = store.liveDir(this.dataDir)
236
+ const incoming = dest + '.incoming'
237
+ if (fs.existsSync(incoming)) fs.rmSync(incoming, { recursive: true, force: true })
238
+ git.materializeCommit(this.automationsDir, sha, incoming)
239
+ const next = yaml.loadYamlDir(incoming)
240
+ if (next.errors.length) {
241
+ fs.rmSync(incoming, { recursive: true, force: true })
242
+ const err = next.errors.map((e) => e.file + ': ' + e.message).join('; ')
243
+ throw new Error('YAML invalid, live not changed: ' + err)
244
+ }
245
+ const bak = dest + '.bak'
246
+ if (fs.existsSync(bak)) fs.rmSync(bak, { recursive: true, force: true })
247
+ if (fs.existsSync(dest)) fs.renameSync(dest, bak)
248
+ fs.renameSync(incoming, dest)
249
+ if (fs.existsSync(bak)) fs.rmSync(bak, { recursive: true, force: true })
250
+ this.state.activeSha = sha
251
+ store.saveState(this.dataDir, this.state)
252
+ this.load()
253
+ return this.snapshot()
254
+ }
255
+ }
256
+
257
+ function holdMs (auto) {
258
+ for (const t of auto.trigger || []) {
259
+ if (t.for) {
260
+ try {
261
+ return engine.parseDuration(t.for)
262
+ } catch (_) {
263
+ return 0
264
+ }
265
+ }
266
+ }
267
+ return 0
268
+ }
269
+
270
+ module.exports = { Runtime }
package/lib/scripts.js ADDED
@@ -0,0 +1,120 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const { execFile } = require('child_process')
6
+
7
+ function resolveScript (scriptsDir, file) {
8
+ if (!scriptsDir) {
9
+ throw new Error('scriptsDir is not configured')
10
+ }
11
+ if (!file || typeof file !== 'string') {
12
+ throw new Error('Script file is required')
13
+ }
14
+ if (path.isAbsolute(file)) {
15
+ throw new Error('Script path must be relative to scriptsDir')
16
+ }
17
+ const root = path.resolve(scriptsDir)
18
+ const resolved = path.resolve(root, file)
19
+ const rel = path.relative(root, resolved)
20
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
21
+ throw new Error('Script path escapes scriptsDir')
22
+ }
23
+ if (!fs.existsSync(resolved)) {
24
+ throw new Error('Script not found: ' + file)
25
+ }
26
+ return resolved
27
+ }
28
+
29
+ function interpreterFor (scriptPath) {
30
+ const ext = path.extname(scriptPath).toLowerCase()
31
+ if (ext === '.py') return { cmd: 'python3', args: [scriptPath] }
32
+ if (ext === '.sh') return { cmd: 'bash', args: [scriptPath] }
33
+ return { cmd: scriptPath, args: [] }
34
+ }
35
+
36
+ function parseOutput (stdout) {
37
+ const text = String(stdout || '').trim()
38
+ if (!text) return { stdout: '', value: '' }
39
+ if (
40
+ (text.startsWith('{') && text.endsWith('}')) ||
41
+ (text.startsWith('[') && text.endsWith(']'))
42
+ ) {
43
+ try {
44
+ return { stdout: text, value: JSON.parse(text) }
45
+ } catch (_) {
46
+ /* fall through */
47
+ }
48
+ }
49
+ if (text === 'true') return { stdout: text, value: true }
50
+ if (text === 'false') return { stdout: text, value: false }
51
+ const n = Number(text)
52
+ if (text !== '' && Number.isFinite(n)) return { stdout: text, value: n }
53
+ return { stdout: text, value: text }
54
+ }
55
+
56
+ function runScript (opts) {
57
+ const scriptsDir = opts.scriptsDir
58
+ const file = opts.file
59
+ const extraArgs = Array.isArray(opts.args) ? opts.args.map(String) : []
60
+ const timeout = opts.timeoutMs || 30000
61
+ const env = Object.assign({}, process.env, opts.env || {})
62
+ const exec = opts.execFile || execFile
63
+
64
+ let resolved
65
+ try {
66
+ resolved = resolveScript(scriptsDir, file)
67
+ } catch (err) {
68
+ return Promise.resolve({
69
+ ok: false,
70
+ error: err.message,
71
+ stdout: '',
72
+ stderr: '',
73
+ value: null
74
+ })
75
+ }
76
+
77
+ const interp = interpreterFor(resolved)
78
+ const args = interp.args.concat(extraArgs)
79
+
80
+ return new Promise((resolve) => {
81
+ exec(
82
+ interp.cmd,
83
+ args,
84
+ { cwd: path.resolve(scriptsDir), timeout, env, maxBuffer: 1024 * 1024 },
85
+ (err, stdout, stderr) => {
86
+ const errText = String(stderr || '').trim()
87
+ if (errText) {
88
+ resolve({
89
+ ok: false,
90
+ error: 'stderr: ' + errText,
91
+ stdout: String(stdout || ''),
92
+ stderr: errText,
93
+ value: null
94
+ })
95
+ return
96
+ }
97
+ if (err) {
98
+ resolve({
99
+ ok: false,
100
+ error: err.killed ? 'Script timed out' : err.message,
101
+ stdout: String(stdout || ''),
102
+ stderr: errText,
103
+ value: null
104
+ })
105
+ return
106
+ }
107
+ const parsed = parseOutput(stdout)
108
+ resolve({
109
+ ok: true,
110
+ error: null,
111
+ stdout: parsed.stdout,
112
+ stderr: '',
113
+ value: parsed.value
114
+ })
115
+ }
116
+ )
117
+ })
118
+ }
119
+
120
+ module.exports = { resolveScript, interpreterFor, parseOutput, runScript }
package/lib/store.js ADDED
@@ -0,0 +1,75 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ function emptyState () {
7
+ return {
8
+ helpers: {},
9
+ enabled: {},
10
+ activeSha: null,
11
+ lastRun: {}
12
+ }
13
+ }
14
+
15
+ function loadState (dir) {
16
+ const file = path.join(dir, 'state.json')
17
+ try {
18
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
19
+ return Object.assign(emptyState(), raw)
20
+ } catch (_) {
21
+ return emptyState()
22
+ }
23
+ }
24
+
25
+ function saveState (dir, state) {
26
+ fs.mkdirSync(dir, { recursive: true })
27
+ const tmp = path.join(dir, 'state.json.tmp')
28
+ const file = path.join(dir, 'state.json')
29
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n')
30
+ fs.renameSync(tmp, file)
31
+ }
32
+
33
+ function tracesPath (dir, id) {
34
+ return path.join(dir, 'traces', safeId(id) + '.json')
35
+ }
36
+
37
+ function loadTraces (dir, id) {
38
+ try {
39
+ const data = JSON.parse(fs.readFileSync(tracesPath(dir, id), 'utf8'))
40
+ return Array.isArray(data) ? data : []
41
+ } catch (_) {
42
+ return []
43
+ }
44
+ }
45
+
46
+ function appendTrace (dir, id, record, max) {
47
+ const cap = max > 0 ? max : 20
48
+ const list = loadTraces(dir, id)
49
+ list.unshift(record)
50
+ while (list.length > cap) list.pop()
51
+ const folder = path.join(dir, 'traces')
52
+ fs.mkdirSync(folder, { recursive: true })
53
+ const tmp = tracesPath(dir, id) + '.tmp'
54
+ fs.writeFileSync(tmp, JSON.stringify(list, null, 2) + '\n')
55
+ fs.renameSync(tmp, tracesPath(dir, id))
56
+ return list
57
+ }
58
+
59
+ function liveDir (dir) {
60
+ return path.join(dir, 'live')
61
+ }
62
+
63
+ function safeId (id) {
64
+ return String(id || 'unknown').replace(/[^A-Za-z0-9._-]+/g, '_')
65
+ }
66
+
67
+ module.exports = {
68
+ emptyState,
69
+ loadState,
70
+ saveState,
71
+ loadTraces,
72
+ appendTrace,
73
+ liveDir,
74
+ tracesPath
75
+ }
package/lib/units.js ADDED
@@ -0,0 +1,40 @@
1
+ 'use strict'
2
+
3
+ const TO_SI = {
4
+ m: 1,
5
+ km: 1000,
6
+ nm: 1852,
7
+ kn: 0.514444,
8
+ kt: 0.514444,
9
+ '%': 0.01,
10
+ percent: 0.01,
11
+ s: 1,
12
+ min: 60,
13
+ h: 3600
14
+ }
15
+
16
+ function toSi (value, unit) {
17
+ if (unit == null || unit === '') return value
18
+ const key = String(unit).trim()
19
+ const factor = TO_SI[key] != null ? TO_SI[key] : TO_SI[key.toLowerCase()]
20
+ if (factor == null) {
21
+ throw new Error('Unknown unit: ' + unit)
22
+ }
23
+ return Number(value) * factor
24
+ }
25
+
26
+ function parseDuration (raw) {
27
+ if (raw == null) return 0
28
+ if (typeof raw === 'number' && Number.isFinite(raw)) return raw
29
+ const s = String(raw).trim()
30
+ const m = s.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/i)
31
+ if (!m) throw new Error('Invalid duration: ' + raw)
32
+ const n = Number(m[1])
33
+ const unit = (m[2] || 'ms').toLowerCase()
34
+ if (unit === 'ms') return n
35
+ if (unit === 's') return n * 1000
36
+ if (unit === 'm') return n * 60 * 1000
37
+ return n * 60 * 60 * 1000
38
+ }
39
+
40
+ module.exports = { toSi, parseDuration }