signalk-automation-plugin 0.0.1 → 0.0.2

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 CHANGED
@@ -12,6 +12,7 @@ Not Node-RED. Not a Home Assistant YAML clone. Actions are Signal K PUTs, helper
12
12
  - `default` — set `default` on every start
13
13
  - `none` — do nothing on start
14
14
  - Path triggers, `choose`, zones, cron `schedule`
15
+ - Trigger `round:` quantizes numbers (and lat/lon) so 230.1 V and 230.4 V do not both fire
15
16
  - Actions: `put`, `helper`, `notify`, `delay`, `run`
16
17
  - `run:` executes a file under configured `scriptsDir`. **stdout is the value; any stderr is fail**
17
18
  - Webapp: per-automation on/off, last result, last N decision records, live YAML, activate another git commit
@@ -35,7 +36,7 @@ Do not paste automations into the admin form. Runtime state is `~/.signalk/plugi
35
36
 
36
37
  ## Example
37
38
 
38
- See `examples/automations.yaml`. Copy or point `automationsDir` at a repo of your own; bundled examples are `enabled: false`.
39
+ See `examples/automations.yaml`. Boatnet live YAML is `git@github.com:htool/boatnet_automations.git` (`automationsDir` on the Pi). On/off and Verbose are plugin-data switches, not YAML keys.
39
40
 
40
41
  ```yaml
41
42
  helpers:
@@ -46,18 +47,25 @@ helpers:
46
47
  latch_on_external_put: electrical.switches.starlink.state
47
48
 
48
49
  automations:
49
- - id: start_anchorwatch
50
+ - id: starlink_standby
50
51
  trigger:
51
- - path: winches.windlass.rode
52
- above: 2
53
- unit: m
54
- for: 10s
52
+ - schedule: "0 4 * * *"
53
+ condition:
54
+ - zone: home_harbour
55
+ - helper: starlink_manual
56
+ is: false
55
57
  action:
56
- - put: navigation.anchor.watch
58
+ - put: electrical.switches.starlink.state
57
59
  value: true
60
+ - delay: 15m
61
+ - put: electrical.switches.starlink.state
62
+ value: false
58
63
  ```
59
64
 
60
- Script action:
65
+ ```yaml
66
+ - path: electrical.switches.dolphinCharger.voltage
67
+ round: 1 # nearest volt; 230.1 and 230.4 do not both fire
68
+ ```
61
69
 
62
70
  ```yaml
63
71
  - run:
@@ -5,54 +5,69 @@ helpers:
5
5
  default: false
6
6
  on_start: restore
7
7
  latch_on_external_put: electrical.switches.starlink.state
8
- charge_hold:
8
+ depart_prep:
9
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
10
+ name: Maak klaar voor vertrek
11
+ default: false
12
+ on_start: restore
20
13
 
21
14
  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
15
  - id: shore_charge
35
16
  alias: Walstroom laadbeleid
36
- enabled: false
17
+ zones:
18
+ home_harbour:
19
+ name: Thuishaven
20
+ lat: 52.37
21
+ lon: 5.22
22
+ radius: 150
37
23
  trigger:
38
- - path: electrical.chargers.shore.connected
24
+ - path: sensors.presence.dolphinshelly
39
25
  - path: electrical.batteries.house.capacity.stateOfCharge
40
- - path: navigation.position
26
+ round: 0.01
27
+ - helper: depart_prep
41
28
  choose:
42
29
  - alias: onderweg volladen
43
30
  conditions:
44
- - path: electrical.chargers.shore.connected
31
+ - path: sensors.presence.dolphinshelly
45
32
  is: true
46
33
  - not:
47
34
  zone: home_harbour
48
35
  action:
49
36
  - put: electrical.switches.dolphinCharger.state
50
37
  value: 1
38
+ - alias: klaar voor vertrek laden
39
+ conditions:
40
+ - path: sensors.presence.dolphinshelly
41
+ is: true
42
+ - zone: home_harbour
43
+ - helper: depart_prep
44
+ is: true
45
+ - path: electrical.batteries.house.capacity.stateOfCharge
46
+ below: 0.98
47
+ action:
48
+ - put: electrical.switches.dolphinCharger.state
49
+ value: 1
50
+ - alias: klaar voor vertrek vol
51
+ conditions:
52
+ - path: sensors.presence.dolphinshelly
53
+ is: true
54
+ - zone: home_harbour
55
+ - helper: depart_prep
56
+ is: true
57
+ - path: electrical.batteries.house.capacity.stateOfCharge
58
+ above: 0.97
59
+ action:
60
+ - put: electrical.switches.dolphinCharger.state
61
+ value: 0
62
+ - helper: depart_prep
63
+ value: false
51
64
  - alias: thuis onder 60%
52
65
  conditions:
53
- - path: electrical.chargers.shore.connected
66
+ - path: sensors.presence.dolphinshelly
54
67
  is: true
55
68
  - zone: home_harbour
69
+ - helper: depart_prep
70
+ is: false
56
71
  - path: electrical.batteries.house.capacity.stateOfCharge
57
72
  below: 0.6
58
73
  action:
@@ -60,7 +75,11 @@ automations:
60
75
  value: 1
61
76
  - alias: thuis boven 80%
62
77
  conditions:
78
+ - path: sensors.presence.dolphinshelly
79
+ is: true
63
80
  - zone: home_harbour
81
+ - helper: depart_prep
82
+ is: false
64
83
  - path: electrical.batteries.house.capacity.stateOfCharge
65
84
  above: 0.8
66
85
  action:
@@ -69,10 +88,11 @@ automations:
69
88
 
70
89
  - id: starlink_standby
71
90
  alias: Starlink standby-update
72
- enabled: false
73
91
  trigger:
74
92
  - schedule: "0 4 * * *"
75
93
  condition:
94
+ # home_harbour is defined on shore_charge (Walstroom laadbeleid).
95
+ - zone: home_harbour
76
96
  - helper: starlink_manual
77
97
  is: false
78
98
  action:
@@ -81,18 +101,3 @@ automations:
81
101
  - delay: 15m
82
102
  - put: electrical.switches.starlink.state
83
103
  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 CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { toSi, parseDuration } = require('./units')
4
- const { inZone } = require('./geo')
4
+ const { inZone, positionLatLon, zoneDistanceM } = require('./geo')
5
5
  const { runScript } = require('./scripts')
6
6
  const { helperPath } = require('./helpers')
7
7
 
@@ -12,7 +12,9 @@ function collectPaths (doc) {
12
12
  for (const c of auto.condition || []) addPathsFromClause(paths, c)
13
13
  for (const branch of auto.choose || []) {
14
14
  for (const c of branch.conditions || []) addPathsFromClause(paths, c)
15
+ for (const action of branch.action || []) addPathsFromAction(paths, action)
15
16
  }
17
+ for (const action of auto.action || []) addPathsFromAction(paths, action)
16
18
  if (auto.latch_on_external_put) paths.add(auto.latch_on_external_put)
17
19
  }
18
20
  for (const def of Object.values(doc.helpers || {})) {
@@ -31,6 +33,12 @@ function addPathsFromClause (paths, clause) {
31
33
  if (Array.isArray(clause.any)) clause.any.forEach((c) => addPathsFromClause(paths, c))
32
34
  }
33
35
 
36
+ function addPathsFromAction (paths, action) {
37
+ if (!action || typeof action !== 'object') return
38
+ if (action.put) paths.add(action.put)
39
+ if (action.valueFrom) paths.add(action.valueFrom)
40
+ }
41
+
34
42
  function getByPath (values, p) {
35
43
  if (Object.prototype.hasOwnProperty.call(values, p)) return values[p]
36
44
  return undefined
@@ -81,11 +89,20 @@ function evalClause (clause, ctx) {
81
89
  if (clause.zone) {
82
90
  const pos = getByPath(ctx.values, 'navigation.position')
83
91
  const zone = ctx.zones[clause.zone]
92
+ const ll = positionLatLon(pos)
93
+ const distanceM = zone ? zoneDistanceM(pos, zone) : null
84
94
  const pass = zone ? inZone(pos, zone) : false
85
95
  return {
86
96
  pass,
87
- error: zone ? null : 'Unknown zone: ' + clause.zone,
88
- actual: pos,
97
+ error: zone ? (ll ? null : 'no GPS position') : 'Unknown zone: ' + clause.zone,
98
+ actual: ll
99
+ ? {
100
+ lat: ll.lat,
101
+ lon: ll.lon,
102
+ distance_m: distanceM == null ? null : Math.round(distanceM * 10) / 10,
103
+ radius_m: zone ? Number(zone.radius) : null
104
+ }
105
+ : pos == null ? null : pos,
89
106
  path: 'navigation.position',
90
107
  expected: clause.zone
91
108
  }
@@ -164,16 +181,123 @@ async function evalClauseAsync (clause, ctx) {
164
181
  }
165
182
  }
166
183
 
184
+ function fmtVal (value) {
185
+ if (value === undefined) return 'unset'
186
+ try {
187
+ return JSON.stringify(value)
188
+ } catch (_) {
189
+ return String(value)
190
+ }
191
+ }
192
+
193
+ function clauseLabel (clause, result) {
194
+ if (!clause || typeof clause !== 'object') return ''
195
+ if (clause.not) return 'not ' + clauseLabel(clause.not, result)
196
+ if (Array.isArray(clause.all)) return 'all'
197
+ if (Array.isArray(clause.any)) return 'any'
198
+ if (clause.zone) {
199
+ const dist = result && result.actual && typeof result.actual === 'object'
200
+ ? result.actual.distance_m
201
+ : null
202
+ return 'zone ' + clause.zone + (dist != null ? ' (' + dist + ' m)' : '')
203
+ }
204
+ if (clause.helper) {
205
+ let s = 'helper ' + clause.helper
206
+ if (clause.is !== undefined) s += ' is ' + fmtVal(clause.is)
207
+ if (result && result.actual !== undefined) s += ' (' + fmtVal(result.actual) + ')'
208
+ return s
209
+ }
210
+ if (clause.schedule) return 'schedule ' + clause.schedule
211
+ if (clause.run) {
212
+ const file = typeof clause.run === 'string' ? clause.run : clause.run.file
213
+ return 'run ' + (file || '')
214
+ }
215
+ if (clause.path) {
216
+ let s = clause.path
217
+ if (clause.above !== undefined) s += ' > ' + clause.above
218
+ else if (clause.below !== undefined) s += ' < ' + clause.below
219
+ else if (clause.is !== undefined) s += ' is ' + fmtVal(clause.is)
220
+ else if (clause.equals !== undefined) s += ' = ' + fmtVal(clause.equals)
221
+ if (result && result.actual !== undefined && (typeof result.actual !== 'object' || result.actual == null)) {
222
+ s += ' (' + fmtVal(result.actual) + ')'
223
+ }
224
+ return s
225
+ }
226
+ return ''
227
+ }
228
+
229
+ function markPass (r) {
230
+ if (!r) return '✗'
231
+ if (r.error) return '✗'
232
+ return r.pass ? '✓' : '✗'
233
+ }
234
+
235
+ function withLabel (clause, result) {
236
+ const row = result && typeof result === 'object' ? result : { pass: false }
237
+ row.clause = clause
238
+ row.label = clauseLabel(clause, row)
239
+ return row
240
+ }
241
+
242
+ function triggerFiredLabel (trigger) {
243
+ if (!trigger) return 'evaluated'
244
+ if (trigger.manual) return 'manual Run'
245
+ if (trigger.start) return 'plugin start'
246
+ if (trigger.schedule) return 'schedule ' + trigger.schedule
247
+ if (trigger.path) return trigger.path + ' = ' + fmtVal(trigger.value)
248
+ return 'evaluated'
249
+ }
250
+
251
+ function isBarePathClause (clause) {
252
+ if (!clause || !clause.path) return false
253
+ return !['is', 'equals', 'above', 'below', 'to'].some((k) =>
254
+ Object.prototype.hasOwnProperty.call(clause, k)
255
+ )
256
+ }
257
+
167
258
  async function evalList (list, ctx) {
168
259
  const results = []
169
260
  for (const clause of list || []) {
170
- const r = await evalClauseAsync(clause, ctx)
261
+ const r = withLabel(clause, await evalClauseAsync(clause, ctx))
171
262
  results.push(r)
172
263
  if (!r.pass) return { pass: false, results }
173
264
  }
174
265
  return { pass: true, results }
175
266
  }
176
267
 
268
+ async function evalListAll (list, ctx) {
269
+ const results = []
270
+ for (const clause of list || []) {
271
+ results.push(withLabel(clause, await evalClauseAsync(clause, ctx)))
272
+ }
273
+ const pass = results.every((r) => r.pass && !r.error)
274
+ return { pass, results }
275
+ }
276
+
277
+ function formatVerboseLog (auto, record) {
278
+ const name = (auto && (auto.alias || auto.id)) || (record && record.id) || 'automation'
279
+ const result = record.result || 'skipped'
280
+ const head = name + ' → ' + result + (record.reason ? ' (' + record.reason + ')' : '')
281
+ const lines = [head]
282
+ if (record.firedBy) lines.push(' trigger: ' + record.firedBy)
283
+ for (const r of record.triggerResults || []) {
284
+ if (isBarePathClause(r.clause)) continue
285
+ if (!r.label) continue
286
+ lines.push(' ' + markPass(r) + ' ' + r.label)
287
+ }
288
+ for (const r of record.conditions || []) {
289
+ if (!r.label) continue
290
+ lines.push(' ' + markPass(r) + ' ' + r.label)
291
+ }
292
+ for (const b of record.branches || []) {
293
+ lines.push(' ' + (b.picked ? '→ ' : ' ') + b.alias + (b.picked ? '' : ''))
294
+ for (const c of b.conditions || []) {
295
+ lines.push(' ' + markPass(c) + ' ' + (c.label || ''))
296
+ }
297
+ }
298
+ return lines.join('\n')
299
+ }
300
+
177
301
  function triggerMatchesPath (auto, changedPath) {
178
302
  if (!changedPath) return true
179
303
  const paths = new Set()
@@ -183,6 +307,44 @@ function triggerMatchesPath (auto, changedPath) {
183
307
  return paths.has(changedPath)
184
308
  }
185
309
 
310
+ function triggerRoundStep (auto, path) {
311
+ if (!path) return null
312
+ for (const t of auto.trigger || []) {
313
+ if (t && t.path === path && t.round != null) return Number(t.round)
314
+ }
315
+ return null
316
+ }
317
+
318
+ function quantizeNumber (n, step) {
319
+ const q = Math.round(Number(n) / step) * step
320
+ if (!Number.isFinite(q)) return n
321
+ return Number(q.toPrecision(12))
322
+ }
323
+
324
+ function quantizeValue (value, step) {
325
+ const s = Number(step)
326
+ if (!Number.isFinite(s) || s <= 0) return value
327
+ if (typeof value === 'number' && Number.isFinite(value)) return quantizeNumber(value, s)
328
+ const ll = positionLatLon(value)
329
+ if (ll) {
330
+ return {
331
+ latitude: quantizeNumber(ll.lat, s),
332
+ longitude: quantizeNumber(ll.lon, s)
333
+ }
334
+ }
335
+ return value
336
+ }
337
+
338
+ function quantizedUnchanged (store, auto, path, value) {
339
+ const step = triggerRoundStep(auto, path)
340
+ if (step == null || !Number.isFinite(step) || step <= 0) return false
341
+ const key = auto.id + '\0' + path
342
+ const q = JSON.stringify(quantizeValue(value, step))
343
+ if (store[key] === q) return true
344
+ store[key] = q
345
+ return false
346
+ }
347
+
186
348
  function cronMatch (expr, date) {
187
349
  if (!expr) return false
188
350
  const parts = String(expr).trim().split(/\s+/)
@@ -216,52 +378,66 @@ function fieldMatch (field, value) {
216
378
  })
217
379
  }
218
380
 
381
+ function finishRecord (auto, record) {
382
+ record.verboseLog = formatVerboseLog(auto, record)
383
+ return record
384
+ }
385
+
219
386
  async function evaluateAutomation (auto, ctx) {
220
387
  const record = {
221
388
  ts: new Date(ctx.now || Date.now()).toISOString(),
222
389
  id: auto.id,
223
390
  result: 'skipped',
224
391
  trigger: ctx.trigger || null,
392
+ firedBy: triggerFiredLabel(ctx.trigger),
225
393
  conditions: [],
394
+ branches: [],
226
395
  choose: null,
227
396
  actions: []
228
397
  }
229
398
  if (!ctx.enabled) {
230
399
  record.reason = 'disabled'
231
- return record
400
+ return finishRecord(auto, record)
232
401
  }
233
402
 
234
- const trig = await evalList(auto.trigger || [], ctx)
403
+ const trig = await evalListAll(auto.trigger || [], ctx)
235
404
  record.triggerResults = trig.results
236
405
  if ((auto.trigger || []).length && !trig.pass) {
237
406
  record.reason = 'trigger not matched'
238
- return record
407
+ return finishRecord(auto, record)
239
408
  }
240
409
 
241
- const cond = await evalList(auto.condition || [], ctx)
410
+ const cond = await evalListAll(auto.condition || [], ctx)
242
411
  record.conditions = cond.results
243
412
  if ((auto.condition || []).length && !cond.pass) {
244
413
  const failed = cond.results.find((r) => r.error)
245
414
  record.result = failed ? 'failure' : 'skipped'
246
415
  record.reason = failed ? failed.error : 'condition not met'
247
- return record
416
+ return finishRecord(auto, record)
248
417
  }
249
418
 
250
419
  let actions = auto.action || []
251
420
  if (auto.choose && auto.choose.length) {
252
421
  let picked = null
253
422
  for (const branch of auto.choose) {
254
- const br = await evalList(branch.conditions || [], ctx)
255
- if (br.pass) {
423
+ const br = await evalListAll(branch.conditions || [], ctx)
424
+ const row = {
425
+ alias: branch.alias || branch.id || 'branch',
426
+ pass: br.pass,
427
+ picked: false,
428
+ conditions: br.results
429
+ }
430
+ if (!picked && br.pass) {
256
431
  picked = branch
257
- record.choose = branch.id || branch.alias || 'branch'
432
+ row.picked = true
433
+ record.choose = row.alias
258
434
  actions = branch.action || []
259
- break
260
435
  }
436
+ record.branches.push(row)
261
437
  }
262
438
  if (!picked) {
263
439
  record.reason = 'no matching choose branch'
264
- return record
440
+ return finishRecord(auto, record)
265
441
  }
266
442
  }
267
443
 
@@ -271,11 +447,11 @@ async function evaluateAutomation (auto, ctx) {
271
447
  if (!ran.ok) {
272
448
  record.result = 'failure'
273
449
  record.reason = ran.error
274
- return record
450
+ return finishRecord(auto, record)
275
451
  }
276
452
  }
277
453
  record.result = 'ok'
278
- return record
454
+ return finishRecord(auto, record)
279
455
  }
280
456
 
281
457
  async function runAction (action, ctx) {
@@ -284,15 +460,30 @@ async function runAction (action, ctx) {
284
460
  }
285
461
  if (action.put) {
286
462
  try {
287
- await ctx.put(action.put, action.value)
288
- return { ok: true, type: 'put', path: action.put, value: action.value }
463
+ const value = Object.prototype.hasOwnProperty.call(action, 'valueFrom')
464
+ ? getByPath(ctx.values, action.valueFrom)
465
+ : action.value
466
+ if (Object.prototype.hasOwnProperty.call(action, 'valueFrom') && value === undefined) {
467
+ return { ok: false, type: 'put', path: action.put, error: 'valueFrom missing: ' + action.valueFrom }
468
+ }
469
+ const previous = getByPath(ctx.values, action.put)
470
+ const changed = !sameValue(previous, value)
471
+ if (changed || ctx.skipUnchanged === false) await ctx.put(action.put, value)
472
+ return {
473
+ ok: true,
474
+ type: 'put',
475
+ path: action.put,
476
+ value,
477
+ previous,
478
+ changed
479
+ }
289
480
  } catch (err) {
290
481
  return { ok: false, type: 'put', path: action.put, error: err.message }
291
482
  }
292
483
  }
293
484
  if (action.helper) {
294
485
  try {
295
- ctx.setHelper(action.helper, action.value)
486
+ await ctx.setHelper(action.helper, action.value)
296
487
  return { ok: true, type: 'helper', helper: action.helper, value: action.value }
297
488
  } catch (err) {
298
489
  return { ok: false, type: 'helper', helper: action.helper, error: err.message }
@@ -309,6 +500,7 @@ async function runAction (action, ctx) {
309
500
  }
310
501
  if (action.delay != null) {
311
502
  const ms = parseDuration(action.delay)
503
+ if (ctx.skipDelay) return { ok: true, type: 'delay', ms, skipped: true }
312
504
  await ctx.sleep(ms)
313
505
  return { ok: true, type: 'delay', ms }
314
506
  }
@@ -326,6 +518,19 @@ async function runAction (action, ctx) {
326
518
  return { ok: false, error: 'unknown action' }
327
519
  }
328
520
 
521
+ function sameValue (a, b) {
522
+ if (a === b) return true
523
+ if (a == null || b == null) return a === b
524
+ if (typeof a === 'object' || typeof b === 'object') {
525
+ try {
526
+ return JSON.stringify(a) === JSON.stringify(b)
527
+ } catch (_) {
528
+ return false
529
+ }
530
+ }
531
+ return false
532
+ }
533
+
329
534
  function makeRunScript (opts) {
330
535
  return function (spec) {
331
536
  return runScript({
@@ -343,10 +548,18 @@ module.exports = {
343
548
  evalClause,
344
549
  evalClauseAsync,
345
550
  evalList,
551
+ evalListAll,
346
552
  evaluateAutomation,
347
553
  triggerMatchesPath,
554
+ triggerRoundStep,
555
+ quantizeValue,
556
+ quantizedUnchanged,
348
557
  cronMatch,
349
558
  runAction,
350
559
  makeRunScript,
351
- parseDuration
560
+ parseDuration,
561
+ sameValue,
562
+ clauseLabel,
563
+ triggerFiredLabel,
564
+ formatVerboseLog
352
565
  }
package/lib/geo.js CHANGED
@@ -13,15 +13,23 @@ function haversineM (a, b) {
13
13
  return 2 * EARTH_M * Math.asin(Math.min(1, Math.sqrt(h)))
14
14
  }
15
15
 
16
+ function positionLatLon (position) {
17
+ if (!position || typeof position !== 'object') return null
18
+ const lat = Number(position.latitude != null ? position.latitude : position.lat)
19
+ const lon = Number(position.longitude != null ? position.longitude : position.lon)
20
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null
21
+ return { lat, lon }
22
+ }
23
+
24
+ function zoneDistanceM (position, zone) {
25
+ const pos = positionLatLon(position)
26
+ if (!pos || !zone) return null
27
+ return haversineM(pos, { lat: Number(zone.lat), lon: Number(zone.lon) })
28
+ }
29
+
16
30
  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
- )
31
+ const d = zoneDistanceM(position, zone)
32
+ if (d == null || !zone) return false
25
33
  return d <= Number(zone.radius)
26
34
  }
27
35
 
@@ -29,4 +37,4 @@ function toRad (deg) {
29
37
  return (Number(deg) * Math.PI) / 180
30
38
  }
31
39
 
32
- module.exports = { haversineM, inZone }
40
+ module.exports = { haversineM, inZone, positionLatLon, zoneDistanceM }
package/lib/git.js CHANGED
@@ -46,6 +46,37 @@ function currentSha (dir) {
46
46
  }
47
47
  }
48
48
 
49
+ function repoRelPath (repoDir) {
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 { root, rel: '' }
54
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
55
+ throw new Error('automationsDir is outside the git repository')
56
+ }
57
+ return { root, rel }
58
+ }
59
+
60
+ function diffRange (repoDir, fromSha, toSha) {
61
+ if (!toSha) 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', toSha], repoDir)
64
+ const from = fromSha || currentSha(repoDir)
65
+ if (!from || from === toSha) {
66
+ return { from: from || null, to: toSha, diff: '' }
67
+ }
68
+ runGit(['cat-file', '-t', from], repoDir)
69
+ const { root, rel } = repoRelPath(repoDir)
70
+ const args = ['diff', '--find-renames', from, toSha]
71
+ if (rel) args.push('--', rel)
72
+ const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 2 * 1024 * 1024 })
73
+ if (r.status !== 0 && r.status !== 1) {
74
+ const err = (r.stderr || r.stdout || '').trim()
75
+ throw new Error('git diff failed: ' + err)
76
+ }
77
+ return { from, to: toSha, diff: (r.stdout || '').replace(/\s+$/, '') }
78
+ }
79
+
49
80
  function archiveTreeish (repoDir, sha) {
50
81
  const root = path.resolve(runGit(['rev-parse', '--show-toplevel'], repoDir))
51
82
  const dir = path.resolve(repoDir)
@@ -108,5 +139,6 @@ module.exports = {
108
139
  listCommits,
109
140
  currentSha,
110
141
  archiveTreeish,
111
- materializeCommit
142
+ materializeCommit,
143
+ diffRange
112
144
  }