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/yaml.js ADDED
@@ -0,0 +1,204 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const yaml = require('js-yaml')
6
+
7
+ const ON_START = ['restore', 'default', 'none']
8
+ const HELPER_TYPES = ['boolean', 'number', 'string', 'select']
9
+
10
+ function loadYamlDir (dir) {
11
+ const result = emptyDoc()
12
+ if (!dir || !fs.existsSync(dir)) {
13
+ return result
14
+ }
15
+ const files = fs
16
+ .readdirSync(dir)
17
+ .filter((f) => /\.ya?ml$/i.test(f))
18
+ .sort()
19
+ for (const file of files) {
20
+ const full = path.join(dir, file)
21
+ let raw
22
+ try {
23
+ raw = fs.readFileSync(full, 'utf8')
24
+ } catch (err) {
25
+ result.errors.push({ file, message: err.message })
26
+ continue
27
+ }
28
+ const loaded = loadYamlText(raw, file)
29
+ mergeDoc(result, loaded)
30
+ }
31
+ return result
32
+ }
33
+
34
+ function loadYamlText (text, file) {
35
+ const result = emptyDoc()
36
+ result.files.push(file)
37
+ let parsed
38
+ try {
39
+ parsed = yaml.load(text) || {}
40
+ } catch (err) {
41
+ result.errors.push({ file, message: err.message })
42
+ return result
43
+ }
44
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
45
+ result.errors.push({ file, message: 'YAML root must be a mapping' })
46
+ return result
47
+ }
48
+ result.rawText[file] = text
49
+ mergeHelpers(result, parsed.helpers, file)
50
+ mergeZones(result, parsed.zones, file)
51
+ mergeAutomations(result, parsed.automations, file)
52
+ return result
53
+ }
54
+
55
+ function emptyDoc () {
56
+ return {
57
+ helpers: {},
58
+ zones: {},
59
+ automations: [],
60
+ errors: [],
61
+ files: [],
62
+ rawText: {}
63
+ }
64
+ }
65
+
66
+ function mergeDoc (into, other) {
67
+ into.files.push.apply(into.files, other.files)
68
+ into.errors.push.apply(into.errors, other.errors)
69
+ Object.assign(into.rawText, other.rawText)
70
+ for (const [id, def] of Object.entries(other.helpers)) {
71
+ if (into.helpers[id]) {
72
+ into.errors.push({ file: def.file, message: 'Duplicate helper id: ' + id })
73
+ continue
74
+ }
75
+ into.helpers[id] = def
76
+ }
77
+ for (const [id, def] of Object.entries(other.zones)) {
78
+ if (into.zones[id]) {
79
+ into.errors.push({ file: def.file, message: 'Duplicate zone id: ' + id })
80
+ continue
81
+ }
82
+ into.zones[id] = def
83
+ }
84
+ const seen = new Set(into.automations.map((a) => a.id))
85
+ for (const auto of other.automations) {
86
+ if (seen.has(auto.id)) {
87
+ into.errors.push({ file: auto.file, message: 'Duplicate automation id: ' + auto.id })
88
+ continue
89
+ }
90
+ seen.add(auto.id)
91
+ into.automations.push(auto)
92
+ }
93
+ }
94
+
95
+ function mergeHelpers (result, helpers, file) {
96
+ if (helpers == null) return
97
+ if (typeof helpers !== 'object' || Array.isArray(helpers)) {
98
+ result.errors.push({ file, message: 'helpers must be a mapping' })
99
+ return
100
+ }
101
+ for (const [id, raw] of Object.entries(helpers)) {
102
+ const def = normalizeHelper(id, raw, file)
103
+ if (def.error) {
104
+ result.errors.push({ file, message: def.error })
105
+ continue
106
+ }
107
+ result.helpers[id] = def
108
+ }
109
+ }
110
+
111
+ function normalizeHelper (id, raw, file) {
112
+ const spec = raw && typeof raw === 'object' ? raw : {}
113
+ const type = spec.type || 'boolean'
114
+ if (!HELPER_TYPES.includes(type)) {
115
+ return { error: 'Helper ' + id + ': unknown type ' + type }
116
+ }
117
+ const onStart = spec.on_start || spec.onStart || 'none'
118
+ if (!ON_START.includes(onStart)) {
119
+ return { error: 'Helper ' + id + ': on_start must be restore|default|none' }
120
+ }
121
+ if (type === 'select' && !Array.isArray(spec.options)) {
122
+ return { error: 'Helper ' + id + ': select requires options' }
123
+ }
124
+ return {
125
+ id,
126
+ file,
127
+ type,
128
+ name: spec.name || id,
129
+ default: spec.default,
130
+ on_start: onStart,
131
+ options: spec.options || [],
132
+ latch_on_external_put: spec.latch_on_external_put || spec.latchOnExternalPut || null
133
+ }
134
+ }
135
+
136
+ function mergeZones (result, zones, file) {
137
+ if (zones == null) return
138
+ if (typeof zones !== 'object' || Array.isArray(zones)) {
139
+ result.errors.push({ file, message: 'zones must be a mapping' })
140
+ return
141
+ }
142
+ for (const [id, raw] of Object.entries(zones)) {
143
+ const spec = raw || {}
144
+ const lat = Number(spec.lat)
145
+ const lon = Number(spec.lon)
146
+ const radius = Number(spec.radius)
147
+ if (![lat, lon, radius].every(Number.isFinite)) {
148
+ result.errors.push({ file, message: 'Zone ' + id + ' needs lat, lon, radius' })
149
+ continue
150
+ }
151
+ result.zones[id] = { id, file, lat, lon, radius, name: spec.name || id }
152
+ }
153
+ }
154
+
155
+ function mergeAutomations (result, list, file) {
156
+ if (list == null) return
157
+ if (!Array.isArray(list)) {
158
+ result.errors.push({ file, message: 'automations must be a list' })
159
+ return
160
+ }
161
+ for (const raw of list) {
162
+ if (!raw || typeof raw !== 'object' || !raw.id) {
163
+ result.errors.push({ file, message: 'Automation needs an id' })
164
+ continue
165
+ }
166
+ result.automations.push({
167
+ id: String(raw.id),
168
+ file,
169
+ alias: raw.alias || raw.id,
170
+ enabled: raw.enabled !== false,
171
+ trigger: asArray(raw.trigger),
172
+ condition: asArray(raw.condition),
173
+ action: asArray(raw.action),
174
+ choose: Array.isArray(raw.choose) ? raw.choose : [],
175
+ debounceMs: raw.debounceMs || 0,
176
+ edge: raw.edge || 'rising'
177
+ })
178
+ }
179
+ }
180
+
181
+ function asArray (value) {
182
+ if (value == null) return []
183
+ return Array.isArray(value) ? value : [value]
184
+ }
185
+
186
+ function combinedYaml (doc) {
187
+ const parts = []
188
+ for (const file of doc.files) {
189
+ const text = doc.rawText[file]
190
+ if (text) {
191
+ parts.push('# file: ' + file + '\n' + text.trim())
192
+ }
193
+ }
194
+ return parts.join('\n\n---\n\n')
195
+ }
196
+
197
+ module.exports = {
198
+ ON_START,
199
+ HELPER_TYPES,
200
+ loadYamlDir,
201
+ loadYamlText,
202
+ emptyDoc,
203
+ combinedYaml
204
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "signalk-automation-plugin",
3
+ "version": "0.0.1",
4
+ "description": "Home Assistant-like YAML automations for Signal K",
5
+ "main": "plugin/index.js",
6
+ "keywords": [
7
+ "signalk-node-server-plugin",
8
+ "signalk-webapp",
9
+ "signalk-category-digital-switching",
10
+ "automation"
11
+ ],
12
+ "license": "ISC",
13
+ "author": "hans@defect.org",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/htool/signalk-automation-plugin.git"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/htool/signalk-automation-plugin/issues"
20
+ },
21
+ "homepage": "https://github.com/htool/signalk-automation-plugin#readme",
22
+ "signalk": {
23
+ "displayName": "Automations"
24
+ },
25
+ "signalk-plugin-enabled-by-default": false,
26
+ "files": [
27
+ "plugin",
28
+ "lib",
29
+ "public",
30
+ "examples",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "scripts": {
35
+ "test": "node --test test/*.test.js && node scripts/test-npm-publish-check.js"
36
+ },
37
+ "dependencies": {
38
+ "js-yaml": "^4.1.0"
39
+ }
40
+ }
@@ -0,0 +1,340 @@
1
+ 'use strict'
2
+
3
+ const path = require('path')
4
+ const { Runtime } = require('../lib/runtime')
5
+ const helpers = require('../lib/helpers')
6
+
7
+ const PLUGIN_ID = 'signalk-automation-plugin'
8
+
9
+ function sendJson (res, body, status) {
10
+ res.statusCode = status || 200
11
+ res.setHeader('Content-Type', 'application/json')
12
+ res.end(JSON.stringify(body))
13
+ }
14
+
15
+ function readJson (req) {
16
+ return new Promise((resolve, reject) => {
17
+ if (req.body && typeof req.body === 'object' && !Buffer.isBuffer(req.body)) {
18
+ resolve(req.body)
19
+ return
20
+ }
21
+ let raw = ''
22
+ req.on('data', (c) => {
23
+ raw += c
24
+ if (raw.length > 1e6) {
25
+ reject(new Error('body too large'))
26
+ }
27
+ })
28
+ req.on('end', () => {
29
+ if (!raw) {
30
+ resolve({})
31
+ return
32
+ }
33
+ try {
34
+ resolve(JSON.parse(raw))
35
+ } catch (err) {
36
+ reject(err)
37
+ }
38
+ })
39
+ req.on('error', reject)
40
+ })
41
+ }
42
+
43
+ module.exports = function (app) {
44
+ const plugin = {}
45
+ plugin.id = PLUGIN_ID
46
+ plugin.name = 'Automations'
47
+ plugin.description =
48
+ 'YAML automations for Signal K: helpers, path triggers, PUT/notify/script actions, git-live YAML.'
49
+
50
+ let runtime = null
51
+ let options = {}
52
+ const timers = []
53
+ const unsubscribes = []
54
+
55
+ plugin.schema = {
56
+ type: 'object',
57
+ properties: {
58
+ automationsDir: {
59
+ type: 'string',
60
+ title: 'Automations git / YAML directory',
61
+ description: 'Folder with *.yaml (preferably a git repo). Live snapshot is stored in plugin data.',
62
+ default: ''
63
+ },
64
+ scriptsDir: {
65
+ type: 'string',
66
+ title: 'Allowed scripts directory',
67
+ description: 'run: actions may only execute files under this path (relative paths in YAML).',
68
+ default: ''
69
+ },
70
+ traceCount: {
71
+ type: 'number',
72
+ title: 'Decision records to keep per automation',
73
+ default: 20,
74
+ minimum: 1
75
+ },
76
+ scriptTimeoutSeconds: {
77
+ type: 'number',
78
+ title: 'Script timeout (seconds)',
79
+ default: 30,
80
+ minimum: 1
81
+ }
82
+ }
83
+ }
84
+
85
+ function snapshot () {
86
+ if (!runtime) {
87
+ return {
88
+ started: false,
89
+ automations: [],
90
+ helpers: [],
91
+ errors: [],
92
+ yaml: '',
93
+ commits: []
94
+ }
95
+ }
96
+ return runtime.snapshot()
97
+ }
98
+
99
+ function requireRuntime (res) {
100
+ if (!runtime) {
101
+ sendJson(res, { error: 'plugin not started' }, 409)
102
+ return null
103
+ }
104
+ return runtime
105
+ }
106
+
107
+ async function handleLive (req, res) {
108
+ const rt = requireRuntime(res)
109
+ if (!rt) return
110
+ try {
111
+ const body = await readJson(req)
112
+ const snap = rt.activateCommit(body.sha)
113
+ sendJson(res, snap)
114
+ } catch (err) {
115
+ sendJson(res, { error: err.message }, 400)
116
+ }
117
+ }
118
+
119
+ async function handleEnabled (req, res, id) {
120
+ const rt = requireRuntime(res)
121
+ if (!rt) return
122
+ try {
123
+ const body = await readJson(req)
124
+ rt.setEnabled(id, body.enabled)
125
+ sendJson(res, { id, enabled: rt.isEnabled({ id, enabled: true }) && body.enabled })
126
+ } catch (err) {
127
+ sendJson(res, { error: err.message }, 400)
128
+ }
129
+ }
130
+
131
+ async function handleHelperPut (req, res, id) {
132
+ const rt = requireRuntime(res)
133
+ if (!rt) return
134
+ try {
135
+ const body = await readJson(req)
136
+ const value = rt.setHelper(id, body.value)
137
+ emitHelper(id, value)
138
+ sendJson(res, { id, value })
139
+ } catch (err) {
140
+ sendJson(res, { error: err.message }, 400)
141
+ }
142
+ }
143
+
144
+ function handleHelperReset (req, res, id) {
145
+ const rt = requireRuntime(res)
146
+ if (!rt) return
147
+ try {
148
+ const value = rt.resetHelper(id)
149
+ emitHelper(id, value)
150
+ sendJson(res, { id, value: value == null ? null : value })
151
+ } catch (err) {
152
+ sendJson(res, { error: err.message }, 400)
153
+ }
154
+ }
155
+
156
+ plugin.registerWithRouter = function (router) {
157
+ router.get('/status', (req, res) => sendJson(res, snapshot()))
158
+ router.get('/yaml', (req, res) => sendJson(res, { yaml: snapshot().yaml, sha: snapshot().activeSha }))
159
+ router.get('/commits', (req, res) => sendJson(res, { commits: snapshot().commits, activeSha: snapshot().activeSha }))
160
+ router.get('/automations/:id/traces', (req, res) => {
161
+ const rt = requireRuntime(res)
162
+ if (!rt) return
163
+ sendJson(res, { id: req.params.id, traces: rt.traces(req.params.id) })
164
+ })
165
+ router.post('/live', (req, res) => {
166
+ handleLive(req, res)
167
+ })
168
+ router.post('/automations/:id/enabled', (req, res) => {
169
+ handleEnabled(req, res, req.params.id)
170
+ })
171
+ router.put('/helpers/:id', (req, res) => {
172
+ handleHelperPut(req, res, req.params.id)
173
+ })
174
+ router.post('/helpers/:id/reset', (req, res) => {
175
+ handleHelperReset(req, res, req.params.id)
176
+ })
177
+ }
178
+
179
+ plugin.signalKApiRoutes = function (router) {
180
+ const prefix = '/' + PLUGIN_ID
181
+ router.get(prefix + '/status', (req, res) => sendJson(res, snapshot()))
182
+ router.get(prefix + '/yaml', (req, res) =>
183
+ sendJson(res, { yaml: snapshot().yaml, sha: snapshot().activeSha })
184
+ )
185
+ router.get(prefix + '/commits', (req, res) =>
186
+ sendJson(res, { commits: snapshot().commits, activeSha: snapshot().activeSha })
187
+ )
188
+ router.get(prefix + '/automations/:id/traces', (req, res) => {
189
+ if (!runtime) {
190
+ sendJson(res, { error: 'plugin not started' }, 409)
191
+ return
192
+ }
193
+ sendJson(res, { id: req.params.id, traces: runtime.traces(req.params.id) })
194
+ })
195
+ return router
196
+ }
197
+
198
+ function emitHelper (id, value) {
199
+ const def = runtime && runtime.doc.helpers[id]
200
+ app.handleMessage(PLUGIN_ID, {
201
+ updates: [
202
+ {
203
+ values: [{ path: helpers.helperPath(id), value: value == null ? null : value }],
204
+ meta: [
205
+ {
206
+ path: helpers.helperPath(id),
207
+ value: {
208
+ displayName: def ? def.name : id,
209
+ supportsPut: true
210
+ }
211
+ }
212
+ ]
213
+ }
214
+ ]
215
+ })
216
+ }
217
+
218
+ function putPath (p, value) {
219
+ return new Promise((resolve, reject) => {
220
+ if (typeof app.putSelfPath === 'function') {
221
+ app.putSelfPath(p, value, (err) => (err ? reject(err) : resolve()))
222
+ return
223
+ }
224
+ app.handleMessage(PLUGIN_ID, {
225
+ updates: [{ values: [{ path: p, value }] }]
226
+ })
227
+ resolve()
228
+ })
229
+ }
230
+
231
+ function notify (p, state, message) {
232
+ const npath = String(p).startsWith('notifications.') ? p : 'notifications.' + p
233
+ app.handleMessage(PLUGIN_ID, {
234
+ updates: [
235
+ {
236
+ values: [
237
+ {
238
+ path: npath,
239
+ value: {
240
+ state: state || 'alert',
241
+ message: message || '',
242
+ method: ['visual', 'sound']
243
+ }
244
+ }
245
+ ]
246
+ }
247
+ ]
248
+ })
249
+ }
250
+
251
+ plugin.start = function (opts) {
252
+ options = opts || {}
253
+ const dataDir =
254
+ (app.getDataDirPath && app.getDataDirPath()) ||
255
+ path.join(process.cwd(), 'plugin-data', PLUGIN_ID)
256
+ fsMkdir(dataDir)
257
+
258
+ runtime = new Runtime({
259
+ app,
260
+ pluginId: PLUGIN_ID,
261
+ dataDir,
262
+ automationsDir: options.automationsDir || '',
263
+ scriptsDir: options.scriptsDir || '',
264
+ traceCount: options.traceCount,
265
+ scriptTimeoutSeconds: options.scriptTimeoutSeconds,
266
+ put: putPath,
267
+ notify,
268
+ log: { debug: app.debug ? app.debug.bind(app) : () => {}, error: app.error ? app.error.bind(app) : console.error }
269
+ })
270
+ runtime.load()
271
+
272
+ for (const [id, value] of Object.entries(runtime.helperValues)) {
273
+ emitHelper(id, value)
274
+ if (typeof app.registerPutHandler === 'function') {
275
+ app.registerPutHandler('vessels.self', helpers.helperPath(id), (context, p, v, cb) => {
276
+ try {
277
+ const next = runtime.setHelper(id, v)
278
+ emitHelper(id, next)
279
+ if (cb) cb({ state: 'COMPLETED' })
280
+ } catch (err) {
281
+ if (cb) cb({ state: 'COMPLETED', statusCode: 400, message: err.message })
282
+ }
283
+ })
284
+ }
285
+ }
286
+
287
+ const paths = require('../lib/engine').collectPaths(runtime.doc)
288
+ if (paths.length && app.subscriptionmanager) {
289
+ app.subscriptionmanager.subscribe(
290
+ {
291
+ context: 'vessels.self',
292
+ subscribe: paths.map((p) => ({ path: p }))
293
+ },
294
+ unsubscribes,
295
+ (err) => app.error && app.error(err),
296
+ (delta) => {
297
+ const src = delta && delta.updates && delta.updates[0] && delta.updates[0].$source
298
+ ;(delta.updates || []).forEach((u) => {
299
+ const source = u.$source || src
300
+ ;(u.values || []).forEach((v) => {
301
+ runtime.handlePathChange(v.path, v.value, source).catch((err) => {
302
+ if (app.error) app.error(err)
303
+ })
304
+ })
305
+ })
306
+ }
307
+ )
308
+ }
309
+
310
+ timers.push(setInterval(() => {
311
+ runtime.tickSchedule().catch((err) => app.error && app.error(err))
312
+ }, 15000))
313
+
314
+ if (app.setPluginStatus) {
315
+ const n = runtime.doc.automations.length
316
+ const err = runtime.doc.errors.length
317
+ app.setPluginStatus(n + ' automations' + (err ? ', ' + err + ' YAML error(s)' : ''))
318
+ }
319
+ }
320
+
321
+ plugin.stop = function () {
322
+ timers.splice(0).forEach((t) => clearInterval(t))
323
+ unsubscribes.splice(0).forEach((f) => {
324
+ try {
325
+ f()
326
+ } catch (_) {}
327
+ })
328
+ runtime = null
329
+ }
330
+
331
+ return plugin
332
+ }
333
+
334
+ function fsMkdir (dir) {
335
+ const fs = require('fs')
336
+ fs.mkdirSync(dir, { recursive: true })
337
+ }
338
+
339
+ module.exports.app = 'app'
340
+ module.exports.options = 'options'