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 CHANGED
@@ -11,7 +11,8 @@ Not Node-RED. Not a Home Assistant YAML clone. Actions are Signal K PUTs, helper
11
11
  - `restore` — survive reboot (save in plugin data, restore on start)
12
12
  - `default` — set `default` on every start
13
13
  - `none` — do nothing on start
14
- - Path triggers, `choose`, zones, cron `schedule`
14
+ - Path triggers, `choose`, zones, cron `schedule` (trigger list is **any**; conditions are **all**)
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,128 @@ 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
+
258
+ function triggerListMatched (list, results) {
259
+ if (!(list || []).length) return true
260
+ return (results || []).some((r) => r.pass && !r.error)
261
+ }
262
+
167
263
  async function evalList (list, ctx) {
168
264
  const results = []
169
265
  for (const clause of list || []) {
170
- const r = await evalClauseAsync(clause, ctx)
266
+ const r = withLabel(clause, await evalClauseAsync(clause, ctx))
171
267
  results.push(r)
172
268
  if (!r.pass) return { pass: false, results }
173
269
  }
174
270
  return { pass: true, results }
175
271
  }
176
272
 
273
+ async function evalListAll (list, ctx) {
274
+ const results = []
275
+ for (const clause of list || []) {
276
+ results.push(withLabel(clause, await evalClauseAsync(clause, ctx)))
277
+ }
278
+ const pass = results.every((r) => r.pass && !r.error)
279
+ return { pass, results }
280
+ }
281
+
282
+ function formatVerboseLog (auto, record) {
283
+ const name = (auto && (auto.alias || auto.id)) || (record && record.id) || 'automation'
284
+ const result = record.result || 'skipped'
285
+ const head = name + ' → ' + result + (record.reason ? ' (' + record.reason + ')' : '')
286
+ const lines = [head]
287
+ if (record.firedBy) lines.push(' trigger: ' + record.firedBy)
288
+ for (const r of record.triggerResults || []) {
289
+ if (isBarePathClause(r.clause)) continue
290
+ if (!r.label) continue
291
+ lines.push(' ' + markPass(r) + ' ' + r.label)
292
+ }
293
+ for (const r of record.conditions || []) {
294
+ if (!r.label) continue
295
+ lines.push(' ' + markPass(r) + ' ' + r.label)
296
+ }
297
+ for (const b of record.branches || []) {
298
+ lines.push(' ' + (b.picked ? '→ ' : ' ') + b.alias + (b.picked ? '' : ''))
299
+ for (const c of b.conditions || []) {
300
+ lines.push(' ' + markPass(c) + ' ' + (c.label || ''))
301
+ }
302
+ }
303
+ return lines.join('\n')
304
+ }
305
+
177
306
  function triggerMatchesPath (auto, changedPath) {
178
307
  if (!changedPath) return true
179
308
  const paths = new Set()
@@ -183,6 +312,44 @@ function triggerMatchesPath (auto, changedPath) {
183
312
  return paths.has(changedPath)
184
313
  }
185
314
 
315
+ function triggerRoundStep (auto, path) {
316
+ if (!path) return null
317
+ for (const t of auto.trigger || []) {
318
+ if (t && t.path === path && t.round != null) return Number(t.round)
319
+ }
320
+ return null
321
+ }
322
+
323
+ function quantizeNumber (n, step) {
324
+ const q = Math.round(Number(n) / step) * step
325
+ if (!Number.isFinite(q)) return n
326
+ return Number(q.toPrecision(12))
327
+ }
328
+
329
+ function quantizeValue (value, step) {
330
+ const s = Number(step)
331
+ if (!Number.isFinite(s) || s <= 0) return value
332
+ if (typeof value === 'number' && Number.isFinite(value)) return quantizeNumber(value, s)
333
+ const ll = positionLatLon(value)
334
+ if (ll) {
335
+ return {
336
+ latitude: quantizeNumber(ll.lat, s),
337
+ longitude: quantizeNumber(ll.lon, s)
338
+ }
339
+ }
340
+ return value
341
+ }
342
+
343
+ function quantizedUnchanged (store, auto, path, value) {
344
+ const step = triggerRoundStep(auto, path)
345
+ if (step == null || !Number.isFinite(step) || step <= 0) return false
346
+ const key = auto.id + '\0' + path
347
+ const q = JSON.stringify(quantizeValue(value, step))
348
+ if (store[key] === q) return true
349
+ store[key] = q
350
+ return false
351
+ }
352
+
186
353
  function cronMatch (expr, date) {
187
354
  if (!expr) return false
188
355
  const parts = String(expr).trim().split(/\s+/)
@@ -216,52 +383,66 @@ function fieldMatch (field, value) {
216
383
  })
217
384
  }
218
385
 
386
+ function finishRecord (auto, record) {
387
+ record.verboseLog = formatVerboseLog(auto, record)
388
+ return record
389
+ }
390
+
219
391
  async function evaluateAutomation (auto, ctx) {
220
392
  const record = {
221
393
  ts: new Date(ctx.now || Date.now()).toISOString(),
222
394
  id: auto.id,
223
395
  result: 'skipped',
224
396
  trigger: ctx.trigger || null,
397
+ firedBy: triggerFiredLabel(ctx.trigger),
225
398
  conditions: [],
399
+ branches: [],
226
400
  choose: null,
227
401
  actions: []
228
402
  }
229
403
  if (!ctx.enabled) {
230
404
  record.reason = 'disabled'
231
- return record
405
+ return finishRecord(auto, record)
232
406
  }
233
407
 
234
- const trig = await evalList(auto.trigger || [], ctx)
408
+ const trig = await evalListAll(auto.trigger || [], ctx)
235
409
  record.triggerResults = trig.results
236
- if ((auto.trigger || []).length && !trig.pass) {
410
+ if (!triggerListMatched(auto.trigger, trig.results)) {
237
411
  record.reason = 'trigger not matched'
238
- return record
412
+ return finishRecord(auto, record)
239
413
  }
240
414
 
241
- const cond = await evalList(auto.condition || [], ctx)
415
+ const cond = await evalListAll(auto.condition || [], ctx)
242
416
  record.conditions = cond.results
243
417
  if ((auto.condition || []).length && !cond.pass) {
244
418
  const failed = cond.results.find((r) => r.error)
245
419
  record.result = failed ? 'failure' : 'skipped'
246
420
  record.reason = failed ? failed.error : 'condition not met'
247
- return record
421
+ return finishRecord(auto, record)
248
422
  }
249
423
 
250
424
  let actions = auto.action || []
251
425
  if (auto.choose && auto.choose.length) {
252
426
  let picked = null
253
427
  for (const branch of auto.choose) {
254
- const br = await evalList(branch.conditions || [], ctx)
255
- if (br.pass) {
428
+ const br = await evalListAll(branch.conditions || [], ctx)
429
+ const row = {
430
+ alias: branch.alias || branch.id || 'branch',
431
+ pass: br.pass,
432
+ picked: false,
433
+ conditions: br.results
434
+ }
435
+ if (!picked && br.pass) {
256
436
  picked = branch
257
- record.choose = branch.id || branch.alias || 'branch'
437
+ row.picked = true
438
+ record.choose = row.alias
258
439
  actions = branch.action || []
259
- break
260
440
  }
441
+ record.branches.push(row)
261
442
  }
262
443
  if (!picked) {
263
444
  record.reason = 'no matching choose branch'
264
- return record
445
+ return finishRecord(auto, record)
265
446
  }
266
447
  }
267
448
 
@@ -271,11 +452,11 @@ async function evaluateAutomation (auto, ctx) {
271
452
  if (!ran.ok) {
272
453
  record.result = 'failure'
273
454
  record.reason = ran.error
274
- return record
455
+ return finishRecord(auto, record)
275
456
  }
276
457
  }
277
458
  record.result = 'ok'
278
- return record
459
+ return finishRecord(auto, record)
279
460
  }
280
461
 
281
462
  async function runAction (action, ctx) {
@@ -284,15 +465,30 @@ async function runAction (action, ctx) {
284
465
  }
285
466
  if (action.put) {
286
467
  try {
287
- await ctx.put(action.put, action.value)
288
- return { ok: true, type: 'put', path: action.put, value: action.value }
468
+ const value = Object.prototype.hasOwnProperty.call(action, 'valueFrom')
469
+ ? getByPath(ctx.values, action.valueFrom)
470
+ : action.value
471
+ if (Object.prototype.hasOwnProperty.call(action, 'valueFrom') && value === undefined) {
472
+ return { ok: false, type: 'put', path: action.put, error: 'valueFrom missing: ' + action.valueFrom }
473
+ }
474
+ const previous = getByPath(ctx.values, action.put)
475
+ const changed = !sameValue(previous, value)
476
+ if (changed || ctx.skipUnchanged === false) await ctx.put(action.put, value)
477
+ return {
478
+ ok: true,
479
+ type: 'put',
480
+ path: action.put,
481
+ value,
482
+ previous,
483
+ changed
484
+ }
289
485
  } catch (err) {
290
486
  return { ok: false, type: 'put', path: action.put, error: err.message }
291
487
  }
292
488
  }
293
489
  if (action.helper) {
294
490
  try {
295
- ctx.setHelper(action.helper, action.value)
491
+ await ctx.setHelper(action.helper, action.value)
296
492
  return { ok: true, type: 'helper', helper: action.helper, value: action.value }
297
493
  } catch (err) {
298
494
  return { ok: false, type: 'helper', helper: action.helper, error: err.message }
@@ -309,6 +505,7 @@ async function runAction (action, ctx) {
309
505
  }
310
506
  if (action.delay != null) {
311
507
  const ms = parseDuration(action.delay)
508
+ if (ctx.skipDelay) return { ok: true, type: 'delay', ms, skipped: true }
312
509
  await ctx.sleep(ms)
313
510
  return { ok: true, type: 'delay', ms }
314
511
  }
@@ -326,6 +523,19 @@ async function runAction (action, ctx) {
326
523
  return { ok: false, error: 'unknown action' }
327
524
  }
328
525
 
526
+ function sameValue (a, b) {
527
+ if (a === b) return true
528
+ if (a == null || b == null) return a === b
529
+ if (typeof a === 'object' || typeof b === 'object') {
530
+ try {
531
+ return JSON.stringify(a) === JSON.stringify(b)
532
+ } catch (_) {
533
+ return false
534
+ }
535
+ }
536
+ return false
537
+ }
538
+
329
539
  function makeRunScript (opts) {
330
540
  return function (spec) {
331
541
  return runScript({
@@ -343,10 +553,19 @@ module.exports = {
343
553
  evalClause,
344
554
  evalClauseAsync,
345
555
  evalList,
556
+ evalListAll,
346
557
  evaluateAutomation,
347
558
  triggerMatchesPath,
559
+ triggerRoundStep,
560
+ quantizeValue,
561
+ quantizedUnchanged,
348
562
  cronMatch,
349
563
  runAction,
350
564
  makeRunScript,
351
- parseDuration
565
+ parseDuration,
566
+ sameValue,
567
+ clauseLabel,
568
+ triggerFiredLabel,
569
+ triggerListMatched,
570
+ formatVerboseLog
352
571
  }
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
  }