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/LICENSE +15 -0
- package/README.md +76 -0
- package/examples/automations.yaml +98 -0
- package/examples/scripts/echo.sh +2 -0
- package/examples/scripts/fail.sh +3 -0
- package/lib/engine.js +352 -0
- package/lib/geo.js +32 -0
- package/lib/git.js +112 -0
- package/lib/helpers.js +60 -0
- package/lib/runtime.js +270 -0
- package/lib/scripts.js +120 -0
- package/lib/store.js +75 -0
- package/lib/units.js +40 -0
- package/lib/yaml.js +204 -0
- package/package.json +40 -0
- package/plugin/index.js +340 -0
- package/public/app.js +155 -0
- package/public/index.html +39 -0
- package/public/style.css +70 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hans Tool
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL WARRANTIES OF MERCHANTABILITY
|
|
11
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL,
|
|
12
|
+
DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER
|
|
13
|
+
RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF
|
|
14
|
+
CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
|
|
15
|
+
CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# signalk-automation-plugin
|
|
2
|
+
|
|
3
|
+
YAML automations for [Signal K](https://signalk.org), in the spirit of Home Assistant: files you can git, helpers with restart behaviour, and a small webapp for last-run / decision records.
|
|
4
|
+
|
|
5
|
+
Not Node-RED. Not a Home Assistant YAML clone. Actions are Signal K PUTs, helper writes, notifications, delays, and optional scripts.
|
|
6
|
+
|
|
7
|
+
## In
|
|
8
|
+
|
|
9
|
+
- YAML in an automations directory (preferably a git repo). Live snapshot lives in plugin data, not plugin options.
|
|
10
|
+
- Helpers (`boolean` / `number` / `string` / `select`) with `on_start`:
|
|
11
|
+
- `restore` — survive reboot (save in plugin data, restore on start)
|
|
12
|
+
- `default` — set `default` on every start
|
|
13
|
+
- `none` — do nothing on start
|
|
14
|
+
- Path triggers, `choose`, zones, cron `schedule`
|
|
15
|
+
- Actions: `put`, `helper`, `notify`, `delay`, `run`
|
|
16
|
+
- `run:` executes a file under configured `scriptsDir`. **stdout is the value; any stderr is fail**
|
|
17
|
+
- Webapp: per-automation on/off, last result, last N decision records, live YAML, activate another git commit
|
|
18
|
+
|
|
19
|
+
## Out
|
|
20
|
+
|
|
21
|
+
- Node-RED flows
|
|
22
|
+
- HA templates / Jinja
|
|
23
|
+
- Arbitrary shell strings (only files under `scriptsDir`, no `..`)
|
|
24
|
+
|
|
25
|
+
## Plugin config
|
|
26
|
+
|
|
27
|
+
| Option | Meaning |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| Automations git / YAML directory | Folder with `*.yaml` at the top level. Git repo (or a subdir of one) → commit list in the webapp; “make live” archives only this folder |
|
|
30
|
+
| Allowed scripts directory | Only relative `run.file` paths under this dir |
|
|
31
|
+
| Decision records to keep | Ring buffer per automation |
|
|
32
|
+
| Script timeout | Seconds |
|
|
33
|
+
|
|
34
|
+
Do not paste automations into the admin form. Runtime state is `~/.signalk/plugin-config-data/signalk-automation-plugin/` (`state.json`, `live/`, `traces/`).
|
|
35
|
+
|
|
36
|
+
## Example
|
|
37
|
+
|
|
38
|
+
See `examples/automations.yaml`. Copy or point `automationsDir` at a repo of your own; bundled examples are `enabled: false`.
|
|
39
|
+
|
|
40
|
+
```yaml
|
|
41
|
+
helpers:
|
|
42
|
+
starlink_manual:
|
|
43
|
+
type: boolean
|
|
44
|
+
default: false
|
|
45
|
+
on_start: restore # survive reboot
|
|
46
|
+
latch_on_external_put: electrical.switches.starlink.state
|
|
47
|
+
|
|
48
|
+
automations:
|
|
49
|
+
- id: start_anchorwatch
|
|
50
|
+
trigger:
|
|
51
|
+
- path: winches.windlass.rode
|
|
52
|
+
above: 2
|
|
53
|
+
unit: m
|
|
54
|
+
for: 10s
|
|
55
|
+
action:
|
|
56
|
+
- put: navigation.anchor.watch
|
|
57
|
+
value: true
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Script action:
|
|
61
|
+
|
|
62
|
+
```yaml
|
|
63
|
+
- run:
|
|
64
|
+
file: check-harbour.py
|
|
65
|
+
as: harbour_ok # optional helper id to store stdout
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Webapp
|
|
69
|
+
|
|
70
|
+
Webapps → **Automations**. Reads go through `/signalk/v1/api/signalk-automation-plugin/` (SK 2.x). Writes (on/off, make live, helper reset) use `/plugins/signalk-automation-plugin/` and need an admin session.
|
|
71
|
+
|
|
72
|
+
## Auto-publish
|
|
73
|
+
|
|
74
|
+
GitHub Action `.github/workflows/release.yml` patch-bumps and publishes to npm at most once per UTC day when `plugin/`, `lib/`, or `public/` changed. Trusted Publisher (OIDC), workflow filename **must stay** `release.yml`.
|
|
75
|
+
|
|
76
|
+
The first npm version cannot use OIDC. Publish `0.0.1` once from a logged-in machine, then add the trusted publisher for `htool/signalk-automation-plugin` / `release.yml`.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
helpers:
|
|
2
|
+
starlink_manual:
|
|
3
|
+
type: boolean
|
|
4
|
+
name: Starlink handmatig
|
|
5
|
+
default: false
|
|
6
|
+
on_start: restore
|
|
7
|
+
latch_on_external_put: electrical.switches.starlink.state
|
|
8
|
+
charge_hold:
|
|
9
|
+
type: boolean
|
|
10
|
+
name: Thuis 60–80% laden
|
|
11
|
+
default: true
|
|
12
|
+
on_start: default
|
|
13
|
+
|
|
14
|
+
zones:
|
|
15
|
+
home_harbour:
|
|
16
|
+
name: Thuishaven
|
|
17
|
+
lat: 52.37
|
|
18
|
+
lon: 5.22
|
|
19
|
+
radius: 150
|
|
20
|
+
|
|
21
|
+
automations:
|
|
22
|
+
- id: start_anchorwatch
|
|
23
|
+
alias: Ketting uit → anchorwatch
|
|
24
|
+
enabled: false
|
|
25
|
+
trigger:
|
|
26
|
+
- path: winches.windlass.rode
|
|
27
|
+
above: 2
|
|
28
|
+
unit: m
|
|
29
|
+
for: 10s
|
|
30
|
+
action:
|
|
31
|
+
- put: navigation.anchor.watch
|
|
32
|
+
value: true
|
|
33
|
+
|
|
34
|
+
- id: shore_charge
|
|
35
|
+
alias: Walstroom laadbeleid
|
|
36
|
+
enabled: false
|
|
37
|
+
trigger:
|
|
38
|
+
- path: electrical.chargers.shore.connected
|
|
39
|
+
- path: electrical.batteries.house.capacity.stateOfCharge
|
|
40
|
+
- path: navigation.position
|
|
41
|
+
choose:
|
|
42
|
+
- alias: onderweg volladen
|
|
43
|
+
conditions:
|
|
44
|
+
- path: electrical.chargers.shore.connected
|
|
45
|
+
is: true
|
|
46
|
+
- not:
|
|
47
|
+
zone: home_harbour
|
|
48
|
+
action:
|
|
49
|
+
- put: electrical.switches.dolphinCharger.state
|
|
50
|
+
value: 1
|
|
51
|
+
- alias: thuis onder 60%
|
|
52
|
+
conditions:
|
|
53
|
+
- path: electrical.chargers.shore.connected
|
|
54
|
+
is: true
|
|
55
|
+
- zone: home_harbour
|
|
56
|
+
- path: electrical.batteries.house.capacity.stateOfCharge
|
|
57
|
+
below: 0.6
|
|
58
|
+
action:
|
|
59
|
+
- put: electrical.switches.dolphinCharger.state
|
|
60
|
+
value: 1
|
|
61
|
+
- alias: thuis boven 80%
|
|
62
|
+
conditions:
|
|
63
|
+
- zone: home_harbour
|
|
64
|
+
- path: electrical.batteries.house.capacity.stateOfCharge
|
|
65
|
+
above: 0.8
|
|
66
|
+
action:
|
|
67
|
+
- put: electrical.switches.dolphinCharger.state
|
|
68
|
+
value: 0
|
|
69
|
+
|
|
70
|
+
- id: starlink_standby
|
|
71
|
+
alias: Starlink standby-update
|
|
72
|
+
enabled: false
|
|
73
|
+
trigger:
|
|
74
|
+
- schedule: "0 4 * * *"
|
|
75
|
+
condition:
|
|
76
|
+
- helper: starlink_manual
|
|
77
|
+
is: false
|
|
78
|
+
action:
|
|
79
|
+
- put: electrical.switches.starlink.state
|
|
80
|
+
value: true
|
|
81
|
+
- delay: 15m
|
|
82
|
+
- put: electrical.switches.starlink.state
|
|
83
|
+
value: false
|
|
84
|
+
|
|
85
|
+
- id: harbour_via_script
|
|
86
|
+
alias: Voorbeeld script-conditie
|
|
87
|
+
enabled: false
|
|
88
|
+
trigger:
|
|
89
|
+
- path: electrical.chargers.shore.connected
|
|
90
|
+
is: true
|
|
91
|
+
condition:
|
|
92
|
+
- run: echo.sh
|
|
93
|
+
equals: home
|
|
94
|
+
action:
|
|
95
|
+
- notify:
|
|
96
|
+
path: electrical.chargers.shore
|
|
97
|
+
state: normal
|
|
98
|
+
message: script said home
|
package/lib/engine.js
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { toSi, parseDuration } = require('./units')
|
|
4
|
+
const { inZone } = require('./geo')
|
|
5
|
+
const { runScript } = require('./scripts')
|
|
6
|
+
const { helperPath } = require('./helpers')
|
|
7
|
+
|
|
8
|
+
function collectPaths (doc) {
|
|
9
|
+
const paths = new Set()
|
|
10
|
+
for (const auto of doc.automations || []) {
|
|
11
|
+
for (const t of auto.trigger || []) addPathsFromClause(paths, t)
|
|
12
|
+
for (const c of auto.condition || []) addPathsFromClause(paths, c)
|
|
13
|
+
for (const branch of auto.choose || []) {
|
|
14
|
+
for (const c of branch.conditions || []) addPathsFromClause(paths, c)
|
|
15
|
+
}
|
|
16
|
+
if (auto.latch_on_external_put) paths.add(auto.latch_on_external_put)
|
|
17
|
+
}
|
|
18
|
+
for (const def of Object.values(doc.helpers || {})) {
|
|
19
|
+
if (def.latch_on_external_put) paths.add(def.latch_on_external_put)
|
|
20
|
+
}
|
|
21
|
+
return Array.from(paths)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function addPathsFromClause (paths, clause) {
|
|
25
|
+
if (!clause || typeof clause !== 'object') return
|
|
26
|
+
if (clause.path) paths.add(clause.path)
|
|
27
|
+
if (clause.zone || clause.not && clause.not.zone) paths.add('navigation.position')
|
|
28
|
+
if (clause.helper) paths.add(helperPath(clause.helper))
|
|
29
|
+
if (clause.not) addPathsFromClause(paths, clause.not)
|
|
30
|
+
if (Array.isArray(clause.all)) clause.all.forEach((c) => addPathsFromClause(paths, c))
|
|
31
|
+
if (Array.isArray(clause.any)) clause.any.forEach((c) => addPathsFromClause(paths, c))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getByPath (values, p) {
|
|
35
|
+
if (Object.prototype.hasOwnProperty.call(values, p)) return values[p]
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function compareNumber (actual, op, expected, unit) {
|
|
40
|
+
const a = Number(actual)
|
|
41
|
+
const b = toSi(expected, unit)
|
|
42
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) return false
|
|
43
|
+
if (op === 'above' || op === 'gt') return a > b
|
|
44
|
+
if (op === 'below' || op === 'lt') return a < b
|
|
45
|
+
if (op === 'gte') return a >= b
|
|
46
|
+
if (op === 'lte') return a <= b
|
|
47
|
+
return a === b
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function evalClause (clause, ctx) {
|
|
51
|
+
if (!clause || typeof clause !== 'object') {
|
|
52
|
+
return { pass: false, error: 'empty clause', actual: null }
|
|
53
|
+
}
|
|
54
|
+
if (clause.not) {
|
|
55
|
+
const inner = evalClause(clause.not, ctx)
|
|
56
|
+
return {
|
|
57
|
+
pass: inner.error ? false : !inner.pass,
|
|
58
|
+
error: inner.error,
|
|
59
|
+
actual: inner.actual,
|
|
60
|
+
clause: { not: true }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (Array.isArray(clause.all)) {
|
|
64
|
+
const parts = clause.all.map((c) => evalClause(c, ctx))
|
|
65
|
+
return {
|
|
66
|
+
pass: parts.every((p) => p.pass),
|
|
67
|
+
error: parts.find((p) => p.error)?.error || null,
|
|
68
|
+
actual: parts,
|
|
69
|
+
clause: { all: true }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (Array.isArray(clause.any)) {
|
|
73
|
+
const parts = clause.any.map((c) => evalClause(c, ctx))
|
|
74
|
+
return {
|
|
75
|
+
pass: parts.some((p) => p.pass),
|
|
76
|
+
error: parts.find((p) => p.error)?.error || null,
|
|
77
|
+
actual: parts,
|
|
78
|
+
clause: { any: true }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (clause.zone) {
|
|
82
|
+
const pos = getByPath(ctx.values, 'navigation.position')
|
|
83
|
+
const zone = ctx.zones[clause.zone]
|
|
84
|
+
const pass = zone ? inZone(pos, zone) : false
|
|
85
|
+
return {
|
|
86
|
+
pass,
|
|
87
|
+
error: zone ? null : 'Unknown zone: ' + clause.zone,
|
|
88
|
+
actual: pos,
|
|
89
|
+
path: 'navigation.position',
|
|
90
|
+
expected: clause.zone
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (clause.run) {
|
|
94
|
+
return {
|
|
95
|
+
async: true,
|
|
96
|
+
run: typeof clause.run === 'string' ? { file: clause.run } : clause.run,
|
|
97
|
+
equals: clause.equals,
|
|
98
|
+
is: clause.is
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (clause.schedule) {
|
|
102
|
+
const when = ctx.now ? new Date(ctx.now) : new Date()
|
|
103
|
+
const fromTick = ctx.trigger && ctx.trigger.schedule
|
|
104
|
+
const pass = fromTick ? cronMatch(clause.schedule, when) : false
|
|
105
|
+
return {
|
|
106
|
+
pass,
|
|
107
|
+
error: null,
|
|
108
|
+
actual: clause.schedule,
|
|
109
|
+
expected: fromTick ? ctx.trigger.schedule : 'schedule tick'
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const path = clause.helper ? helperPath(clause.helper) : clause.path
|
|
113
|
+
if (!path) {
|
|
114
|
+
return { pass: false, error: 'clause needs path, helper, zone, or run', actual: null }
|
|
115
|
+
}
|
|
116
|
+
const actual = getByPath(ctx.values, path)
|
|
117
|
+
let pass = false
|
|
118
|
+
if (Object.prototype.hasOwnProperty.call(clause, 'is')) {
|
|
119
|
+
pass = actual === clause.is
|
|
120
|
+
} else if (Object.prototype.hasOwnProperty.call(clause, 'equals')) {
|
|
121
|
+
pass = actual === clause.equals || String(actual) === String(clause.equals)
|
|
122
|
+
} else if (Object.prototype.hasOwnProperty.call(clause, 'above')) {
|
|
123
|
+
pass = compareNumber(actual, 'above', clause.above, clause.unit)
|
|
124
|
+
} else if (Object.prototype.hasOwnProperty.call(clause, 'below')) {
|
|
125
|
+
pass = compareNumber(actual, 'below', clause.below, clause.unit)
|
|
126
|
+
} else if (Object.prototype.hasOwnProperty.call(clause, 'to')) {
|
|
127
|
+
pass = actual === clause.to
|
|
128
|
+
} else {
|
|
129
|
+
pass = actual !== undefined
|
|
130
|
+
}
|
|
131
|
+
return { pass, error: null, actual, path, expected: expectedOf(clause) }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function expectedOf (clause) {
|
|
135
|
+
if (clause.is !== undefined) return clause.is
|
|
136
|
+
if (clause.equals !== undefined) return clause.equals
|
|
137
|
+
if (clause.above !== undefined) return { above: clause.above, unit: clause.unit }
|
|
138
|
+
if (clause.below !== undefined) return { below: clause.below, unit: clause.unit }
|
|
139
|
+
if (clause.to !== undefined) return clause.to
|
|
140
|
+
return true
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function evalClauseAsync (clause, ctx) {
|
|
144
|
+
const first = evalClause(clause, ctx)
|
|
145
|
+
if (!first.async) return first
|
|
146
|
+
const ran = await ctx.runScript(first.run)
|
|
147
|
+
if (!ran.ok) {
|
|
148
|
+
return {
|
|
149
|
+
pass: false,
|
|
150
|
+
error: ran.error,
|
|
151
|
+
actual: ran.stdout,
|
|
152
|
+
run: first.run.file || first.run
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
let pass = true
|
|
156
|
+
if (first.equals !== undefined) pass = ran.value === first.equals || String(ran.value) === String(first.equals)
|
|
157
|
+
else if (first.is !== undefined) pass = ran.value === first.is
|
|
158
|
+
else pass = Boolean(ran.value)
|
|
159
|
+
return {
|
|
160
|
+
pass,
|
|
161
|
+
error: null,
|
|
162
|
+
actual: ran.value,
|
|
163
|
+
run: first.run.file || first.run
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function evalList (list, ctx) {
|
|
168
|
+
const results = []
|
|
169
|
+
for (const clause of list || []) {
|
|
170
|
+
const r = await evalClauseAsync(clause, ctx)
|
|
171
|
+
results.push(r)
|
|
172
|
+
if (!r.pass) return { pass: false, results }
|
|
173
|
+
}
|
|
174
|
+
return { pass: true, results }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function triggerMatchesPath (auto, changedPath) {
|
|
178
|
+
if (!changedPath) return true
|
|
179
|
+
const paths = new Set()
|
|
180
|
+
for (const t of auto.trigger || []) addPathsFromClause(paths, t)
|
|
181
|
+
const hasSchedule = (auto.trigger || []).some((t) => t.schedule)
|
|
182
|
+
if (paths.size === 0) return !hasSchedule
|
|
183
|
+
return paths.has(changedPath)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function cronMatch (expr, date) {
|
|
187
|
+
if (!expr) return false
|
|
188
|
+
const parts = String(expr).trim().split(/\s+/)
|
|
189
|
+
if (parts.length < 5) return false
|
|
190
|
+
const [min, hour, dom, mon, dow] = parts
|
|
191
|
+
return (
|
|
192
|
+
fieldMatch(min, date.getMinutes()) &&
|
|
193
|
+
fieldMatch(hour, date.getHours()) &&
|
|
194
|
+
fieldMatch(dom, date.getDate()) &&
|
|
195
|
+
fieldMatch(mon, date.getMonth() + 1) &&
|
|
196
|
+
fieldMatch(dow, date.getDay())
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function fieldMatch (field, value) {
|
|
201
|
+
if (field === '*') return true
|
|
202
|
+
return String(field)
|
|
203
|
+
.split(',')
|
|
204
|
+
.some((bit) => {
|
|
205
|
+
if (bit.includes('/')) {
|
|
206
|
+
const [range, step] = bit.split('/')
|
|
207
|
+
const st = Number(step)
|
|
208
|
+
if (!Number.isFinite(st) || st <= 0) return false
|
|
209
|
+
if (range === '*') return value % st === 0
|
|
210
|
+
}
|
|
211
|
+
if (bit.includes('-')) {
|
|
212
|
+
const [a, b] = bit.split('-').map(Number)
|
|
213
|
+
return value >= a && value <= b
|
|
214
|
+
}
|
|
215
|
+
return Number(bit) === value
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function evaluateAutomation (auto, ctx) {
|
|
220
|
+
const record = {
|
|
221
|
+
ts: new Date(ctx.now || Date.now()).toISOString(),
|
|
222
|
+
id: auto.id,
|
|
223
|
+
result: 'skipped',
|
|
224
|
+
trigger: ctx.trigger || null,
|
|
225
|
+
conditions: [],
|
|
226
|
+
choose: null,
|
|
227
|
+
actions: []
|
|
228
|
+
}
|
|
229
|
+
if (!ctx.enabled) {
|
|
230
|
+
record.reason = 'disabled'
|
|
231
|
+
return record
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const trig = await evalList(auto.trigger || [], ctx)
|
|
235
|
+
record.triggerResults = trig.results
|
|
236
|
+
if ((auto.trigger || []).length && !trig.pass) {
|
|
237
|
+
record.reason = 'trigger not matched'
|
|
238
|
+
return record
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const cond = await evalList(auto.condition || [], ctx)
|
|
242
|
+
record.conditions = cond.results
|
|
243
|
+
if ((auto.condition || []).length && !cond.pass) {
|
|
244
|
+
const failed = cond.results.find((r) => r.error)
|
|
245
|
+
record.result = failed ? 'failure' : 'skipped'
|
|
246
|
+
record.reason = failed ? failed.error : 'condition not met'
|
|
247
|
+
return record
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let actions = auto.action || []
|
|
251
|
+
if (auto.choose && auto.choose.length) {
|
|
252
|
+
let picked = null
|
|
253
|
+
for (const branch of auto.choose) {
|
|
254
|
+
const br = await evalList(branch.conditions || [], ctx)
|
|
255
|
+
if (br.pass) {
|
|
256
|
+
picked = branch
|
|
257
|
+
record.choose = branch.id || branch.alias || 'branch'
|
|
258
|
+
actions = branch.action || []
|
|
259
|
+
break
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (!picked) {
|
|
263
|
+
record.reason = 'no matching choose branch'
|
|
264
|
+
return record
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for (const action of actions) {
|
|
269
|
+
const ran = await runAction(action, ctx)
|
|
270
|
+
record.actions.push(ran)
|
|
271
|
+
if (!ran.ok) {
|
|
272
|
+
record.result = 'failure'
|
|
273
|
+
record.reason = ran.error
|
|
274
|
+
return record
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
record.result = 'ok'
|
|
278
|
+
return record
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function runAction (action, ctx) {
|
|
282
|
+
if (!action || typeof action !== 'object') {
|
|
283
|
+
return { ok: false, error: 'empty action' }
|
|
284
|
+
}
|
|
285
|
+
if (action.put) {
|
|
286
|
+
try {
|
|
287
|
+
await ctx.put(action.put, action.value)
|
|
288
|
+
return { ok: true, type: 'put', path: action.put, value: action.value }
|
|
289
|
+
} catch (err) {
|
|
290
|
+
return { ok: false, type: 'put', path: action.put, error: err.message }
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (action.helper) {
|
|
294
|
+
try {
|
|
295
|
+
ctx.setHelper(action.helper, action.value)
|
|
296
|
+
return { ok: true, type: 'helper', helper: action.helper, value: action.value }
|
|
297
|
+
} catch (err) {
|
|
298
|
+
return { ok: false, type: 'helper', helper: action.helper, error: err.message }
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (action.notify) {
|
|
302
|
+
const n = action.notify
|
|
303
|
+
try {
|
|
304
|
+
await ctx.notify(n.path, n.state || 'alert', n.message || '')
|
|
305
|
+
return { ok: true, type: 'notify', path: n.path, state: n.state }
|
|
306
|
+
} catch (err) {
|
|
307
|
+
return { ok: false, type: 'notify', error: err.message }
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (action.delay != null) {
|
|
311
|
+
const ms = parseDuration(action.delay)
|
|
312
|
+
await ctx.sleep(ms)
|
|
313
|
+
return { ok: true, type: 'delay', ms }
|
|
314
|
+
}
|
|
315
|
+
if (action.run) {
|
|
316
|
+
const spec = typeof action.run === 'string' ? { file: action.run } : action.run
|
|
317
|
+
const ran = await ctx.runScript(spec)
|
|
318
|
+
if (!ran.ok) {
|
|
319
|
+
return { ok: false, type: 'run', file: spec.file, error: ran.error, stdout: ran.stdout }
|
|
320
|
+
}
|
|
321
|
+
if (action.as) {
|
|
322
|
+
ctx.setHelper(action.as, ran.value)
|
|
323
|
+
}
|
|
324
|
+
return { ok: true, type: 'run', file: spec.file, value: ran.value, stdout: ran.stdout }
|
|
325
|
+
}
|
|
326
|
+
return { ok: false, error: 'unknown action' }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function makeRunScript (opts) {
|
|
330
|
+
return function (spec) {
|
|
331
|
+
return runScript({
|
|
332
|
+
scriptsDir: opts.scriptsDir,
|
|
333
|
+
file: spec.file,
|
|
334
|
+
args: spec.args,
|
|
335
|
+
timeoutMs: opts.timeoutMs,
|
|
336
|
+
env: spec.env
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
module.exports = {
|
|
342
|
+
collectPaths,
|
|
343
|
+
evalClause,
|
|
344
|
+
evalClauseAsync,
|
|
345
|
+
evalList,
|
|
346
|
+
evaluateAutomation,
|
|
347
|
+
triggerMatchesPath,
|
|
348
|
+
cronMatch,
|
|
349
|
+
runAction,
|
|
350
|
+
makeRunScript,
|
|
351
|
+
parseDuration
|
|
352
|
+
}
|
package/lib/geo.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const EARTH_M = 6371000
|
|
4
|
+
|
|
5
|
+
function haversineM (a, b) {
|
|
6
|
+
const lat1 = toRad(a.lat)
|
|
7
|
+
const lat2 = toRad(b.lat)
|
|
8
|
+
const dLat = toRad(b.lat - a.lat)
|
|
9
|
+
const dLon = toRad(b.lon - a.lon)
|
|
10
|
+
const h =
|
|
11
|
+
Math.sin(dLat / 2) ** 2 +
|
|
12
|
+
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2
|
|
13
|
+
return 2 * EARTH_M * Math.asin(Math.min(1, Math.sqrt(h)))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function inZone (position, zone) {
|
|
17
|
+
if (!position || !zone) return false
|
|
18
|
+
if (!Number.isFinite(position.latitude) || !Number.isFinite(position.longitude)) {
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
const d = haversineM(
|
|
22
|
+
{ lat: position.latitude, lon: position.longitude },
|
|
23
|
+
{ lat: zone.lat, lon: zone.lon }
|
|
24
|
+
)
|
|
25
|
+
return d <= Number(zone.radius)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function toRad (deg) {
|
|
29
|
+
return (Number(deg) * Math.PI) / 180
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { haversineM, inZone }
|
package/lib/git.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const { spawnSync } = require('child_process')
|
|
6
|
+
|
|
7
|
+
function runGit (args, cwd) {
|
|
8
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8' })
|
|
9
|
+
if (r.status !== 0) {
|
|
10
|
+
const err = (r.stderr || r.stdout || '').trim()
|
|
11
|
+
throw new Error('git ' + args.join(' ') + ' failed: ' + err)
|
|
12
|
+
}
|
|
13
|
+
return (r.stdout || '').trim()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isGitRepo (dir) {
|
|
17
|
+
if (!dir || !fs.existsSync(dir)) return false
|
|
18
|
+
try {
|
|
19
|
+
runGit(['rev-parse', '--is-inside-work-tree'], dir)
|
|
20
|
+
return true
|
|
21
|
+
} catch (_) {
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function listCommits (dir, limit) {
|
|
27
|
+
if (!isGitRepo(dir)) return []
|
|
28
|
+
const n = limit > 0 ? limit : 30
|
|
29
|
+
const out = runGit(
|
|
30
|
+
['log', '-n', String(n), '--pretty=format:%H%x09%cI%x09%s'],
|
|
31
|
+
dir
|
|
32
|
+
)
|
|
33
|
+
if (!out) return []
|
|
34
|
+
return out.split('\n').map((line) => {
|
|
35
|
+
const [sha, date, ...rest] = line.split('\t')
|
|
36
|
+
return { sha, date, subject: rest.join('\t') }
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function currentSha (dir) {
|
|
41
|
+
if (!isGitRepo(dir)) return null
|
|
42
|
+
try {
|
|
43
|
+
return runGit(['rev-parse', 'HEAD'], dir)
|
|
44
|
+
} catch (_) {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function archiveTreeish (repoDir, sha) {
|
|
50
|
+
const root = path.resolve(runGit(['rev-parse', '--show-toplevel'], repoDir))
|
|
51
|
+
const dir = path.resolve(repoDir)
|
|
52
|
+
const rel = path.relative(root, dir).replace(/\\/g, '/')
|
|
53
|
+
if (!rel || rel === '.') return sha
|
|
54
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
55
|
+
throw new Error('automationsDir is outside the git repository')
|
|
56
|
+
}
|
|
57
|
+
return sha + ':' + rel
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function materializeCommit (repoDir, sha, destDir) {
|
|
61
|
+
if (!sha) throw new Error('Commit sha is required')
|
|
62
|
+
if (!isGitRepo(repoDir)) throw new Error('automationsDir is not a git repository')
|
|
63
|
+
runGit(['cat-file', '-t', sha], repoDir)
|
|
64
|
+
const treeish = archiveTreeish(repoDir, sha)
|
|
65
|
+
const root = path.resolve(runGit(['rev-parse', '--show-toplevel'], repoDir))
|
|
66
|
+
const parent = path.dirname(path.resolve(destDir))
|
|
67
|
+
fs.mkdirSync(parent, { recursive: true })
|
|
68
|
+
const tmp = fs.mkdtempSync(path.join(parent, 'live-tmp-'))
|
|
69
|
+
try {
|
|
70
|
+
const archive = spawnSync('git', ['archive', '--format=tar', treeish], {
|
|
71
|
+
cwd: root,
|
|
72
|
+
encoding: 'buffer',
|
|
73
|
+
maxBuffer: 20 * 1024 * 1024
|
|
74
|
+
})
|
|
75
|
+
if (archive.status !== 0) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
'git archive failed: ' + String(archive.stderr || archive.stdout || '')
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
const tar = spawnSync('tar', ['-x', '-C', tmp], {
|
|
81
|
+
input: archive.stdout,
|
|
82
|
+
encoding: 'buffer'
|
|
83
|
+
})
|
|
84
|
+
if (tar.status !== 0) {
|
|
85
|
+
throw new Error('tar extract failed: ' + String(tar.stderr || ''))
|
|
86
|
+
}
|
|
87
|
+
const bak = destDir + '.bak'
|
|
88
|
+
if (fs.existsSync(bak)) fs.rmSync(bak, { recursive: true, force: true })
|
|
89
|
+
if (fs.existsSync(destDir)) fs.renameSync(destDir, bak)
|
|
90
|
+
try {
|
|
91
|
+
fs.renameSync(tmp, destDir)
|
|
92
|
+
if (fs.existsSync(bak)) fs.rmSync(bak, { recursive: true, force: true })
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (fs.existsSync(bak) && !fs.existsSync(destDir)) {
|
|
95
|
+
fs.renameSync(bak, destDir)
|
|
96
|
+
}
|
|
97
|
+
throw err
|
|
98
|
+
}
|
|
99
|
+
} finally {
|
|
100
|
+
if (fs.existsSync(tmp)) fs.rmSync(tmp, { recursive: true, force: true })
|
|
101
|
+
}
|
|
102
|
+
return destDir
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = {
|
|
106
|
+
runGit,
|
|
107
|
+
isGitRepo,
|
|
108
|
+
listCommits,
|
|
109
|
+
currentSha,
|
|
110
|
+
archiveTreeish,
|
|
111
|
+
materializeCommit
|
|
112
|
+
}
|