signalk-automation-plugin 0.0.3 → 0.0.5
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 +8 -8
- package/examples/automations.yaml +39 -37
- package/lib/engine.js +37 -9
- package/lib/runtime.js +16 -3
- package/package.json +1 -1
- package/plugin/index.js +56 -9
- package/public/app.js +14 -5
- package/public/index.html +2 -2
package/README.md
CHANGED
|
@@ -36,34 +36,34 @@ Do not paste automations into the admin form. Runtime state is `~/.signalk/plugi
|
|
|
36
36
|
|
|
37
37
|
## Example
|
|
38
38
|
|
|
39
|
-
See `examples/automations.yaml
|
|
39
|
+
See `examples/automations.yaml` (fictional sample). Live YAML is whatever directory you set as `automationsDir` — usually a separate git. On/off and Verbose are plugin-data switches, not YAML keys.
|
|
40
40
|
|
|
41
41
|
```yaml
|
|
42
42
|
helpers:
|
|
43
|
-
|
|
43
|
+
dish_manual:
|
|
44
44
|
type: boolean
|
|
45
45
|
default: false
|
|
46
46
|
on_start: restore # survive reboot
|
|
47
|
-
latch_on_external_put: electrical.switches.
|
|
47
|
+
latch_on_external_put: electrical.switches.dish.state
|
|
48
48
|
|
|
49
49
|
automations:
|
|
50
|
-
- id:
|
|
50
|
+
- id: dish_standby
|
|
51
51
|
trigger:
|
|
52
52
|
- schedule: "0 4 * * *"
|
|
53
53
|
condition:
|
|
54
54
|
- zone: home_harbour
|
|
55
|
-
- helper:
|
|
55
|
+
- helper: dish_manual
|
|
56
56
|
is: false
|
|
57
57
|
action:
|
|
58
|
-
- put: electrical.switches.
|
|
58
|
+
- put: electrical.switches.dish.state
|
|
59
59
|
value: true
|
|
60
60
|
- delay: 15m
|
|
61
|
-
- put: electrical.switches.
|
|
61
|
+
- put: electrical.switches.dish.state
|
|
62
62
|
value: false
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
```yaml
|
|
66
|
-
- path: electrical.switches.
|
|
66
|
+
- path: electrical.switches.charger.voltage
|
|
67
67
|
round: 1 # nearest volt; 230.1 and 230.4 do not both fire
|
|
68
68
|
```
|
|
69
69
|
|
|
@@ -1,103 +1,105 @@
|
|
|
1
|
+
# Bundled sample only. Site YAML belongs in the git you set as automationsDir,
|
|
2
|
+
# not in this plugin.
|
|
3
|
+
|
|
1
4
|
helpers:
|
|
2
|
-
|
|
5
|
+
dish_manual:
|
|
3
6
|
type: boolean
|
|
4
|
-
name:
|
|
7
|
+
name: Dish manual
|
|
5
8
|
default: false
|
|
6
9
|
on_start: restore
|
|
7
|
-
latch_on_external_put: electrical.switches.
|
|
8
|
-
|
|
10
|
+
latch_on_external_put: electrical.switches.dish.state
|
|
11
|
+
trip_prep:
|
|
9
12
|
type: boolean
|
|
10
|
-
name:
|
|
13
|
+
name: Prepare to leave
|
|
11
14
|
default: false
|
|
12
15
|
on_start: restore
|
|
13
16
|
|
|
14
17
|
automations:
|
|
15
18
|
- id: shore_charge
|
|
16
|
-
alias:
|
|
19
|
+
alias: Shore charge
|
|
17
20
|
zones:
|
|
18
21
|
home_harbour:
|
|
19
|
-
name:
|
|
20
|
-
lat: 52.
|
|
21
|
-
lon:
|
|
22
|
+
name: Home harbour
|
|
23
|
+
lat: 52.1
|
|
24
|
+
lon: 4.9
|
|
22
25
|
radius: 150
|
|
23
26
|
trigger:
|
|
24
|
-
- path: sensors.presence.
|
|
27
|
+
- path: sensors.presence.shore
|
|
25
28
|
- path: electrical.batteries.house.capacity.stateOfCharge
|
|
26
29
|
round: 0.01
|
|
27
|
-
- helper:
|
|
30
|
+
- helper: trip_prep
|
|
28
31
|
choose:
|
|
29
|
-
- alias:
|
|
32
|
+
- alias: charge underway
|
|
30
33
|
conditions:
|
|
31
|
-
- path: sensors.presence.
|
|
34
|
+
- path: sensors.presence.shore
|
|
32
35
|
is: true
|
|
33
36
|
- not:
|
|
34
37
|
zone: home_harbour
|
|
35
38
|
action:
|
|
36
|
-
- put: electrical.switches.
|
|
39
|
+
- put: electrical.switches.charger.state
|
|
37
40
|
value: 1
|
|
38
|
-
- alias:
|
|
41
|
+
- alias: charge to leave
|
|
39
42
|
conditions:
|
|
40
|
-
- path: sensors.presence.
|
|
43
|
+
- path: sensors.presence.shore
|
|
41
44
|
is: true
|
|
42
45
|
- zone: home_harbour
|
|
43
|
-
- helper:
|
|
46
|
+
- helper: trip_prep
|
|
44
47
|
is: true
|
|
45
48
|
- path: electrical.batteries.house.capacity.stateOfCharge
|
|
46
49
|
below: 0.98
|
|
47
50
|
action:
|
|
48
|
-
- put: electrical.switches.
|
|
51
|
+
- put: electrical.switches.charger.state
|
|
49
52
|
value: 1
|
|
50
|
-
- alias:
|
|
53
|
+
- alias: leave charged
|
|
51
54
|
conditions:
|
|
52
|
-
- path: sensors.presence.
|
|
55
|
+
- path: sensors.presence.shore
|
|
53
56
|
is: true
|
|
54
57
|
- zone: home_harbour
|
|
55
|
-
- helper:
|
|
58
|
+
- helper: trip_prep
|
|
56
59
|
is: true
|
|
57
60
|
- path: electrical.batteries.house.capacity.stateOfCharge
|
|
58
61
|
above: 0.97
|
|
59
62
|
action:
|
|
60
|
-
- put: electrical.switches.
|
|
63
|
+
- put: electrical.switches.charger.state
|
|
61
64
|
value: 0
|
|
62
|
-
- helper:
|
|
65
|
+
- helper: trip_prep
|
|
63
66
|
value: false
|
|
64
|
-
- alias:
|
|
67
|
+
- alias: home below 60%
|
|
65
68
|
conditions:
|
|
66
|
-
- path: sensors.presence.
|
|
69
|
+
- path: sensors.presence.shore
|
|
67
70
|
is: true
|
|
68
71
|
- zone: home_harbour
|
|
69
|
-
- helper:
|
|
72
|
+
- helper: trip_prep
|
|
70
73
|
is: false
|
|
71
74
|
- path: electrical.batteries.house.capacity.stateOfCharge
|
|
72
75
|
below: 0.6
|
|
73
76
|
action:
|
|
74
|
-
- put: electrical.switches.
|
|
77
|
+
- put: electrical.switches.charger.state
|
|
75
78
|
value: 1
|
|
76
|
-
- alias:
|
|
79
|
+
- alias: home above 80%
|
|
77
80
|
conditions:
|
|
78
|
-
- path: sensors.presence.
|
|
81
|
+
- path: sensors.presence.shore
|
|
79
82
|
is: true
|
|
80
83
|
- zone: home_harbour
|
|
81
|
-
- helper:
|
|
84
|
+
- helper: trip_prep
|
|
82
85
|
is: false
|
|
83
86
|
- path: electrical.batteries.house.capacity.stateOfCharge
|
|
84
87
|
above: 0.8
|
|
85
88
|
action:
|
|
86
|
-
- put: electrical.switches.
|
|
89
|
+
- put: electrical.switches.charger.state
|
|
87
90
|
value: 0
|
|
88
91
|
|
|
89
|
-
- id:
|
|
90
|
-
alias:
|
|
92
|
+
- id: dish_standby
|
|
93
|
+
alias: Dish standby update
|
|
91
94
|
trigger:
|
|
92
95
|
- schedule: "0 4 * * *"
|
|
93
96
|
condition:
|
|
94
|
-
# home_harbour is defined on shore_charge (Walstroom laadbeleid).
|
|
95
97
|
- zone: home_harbour
|
|
96
|
-
- helper:
|
|
98
|
+
- helper: dish_manual
|
|
97
99
|
is: false
|
|
98
100
|
action:
|
|
99
|
-
- put: electrical.switches.
|
|
101
|
+
- put: electrical.switches.dish.state
|
|
100
102
|
value: true
|
|
101
103
|
- delay: 15m
|
|
102
|
-
- put: electrical.switches.
|
|
104
|
+
- put: electrical.switches.dish.state
|
|
103
105
|
value: false
|
package/lib/engine.js
CHANGED
|
@@ -69,7 +69,7 @@ function evalClause (clause, ctx) {
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
if (Array.isArray(clause.all)) {
|
|
72
|
-
const parts = clause.all.map((c) => evalClause(c, ctx))
|
|
72
|
+
const parts = clause.all.map((c) => withLabel(c, evalClause(c, ctx)))
|
|
73
73
|
return {
|
|
74
74
|
pass: parts.every((p) => p.pass),
|
|
75
75
|
error: parts.find((p) => p.error)?.error || null,
|
|
@@ -78,10 +78,11 @@ function evalClause (clause, ctx) {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
if (Array.isArray(clause.any)) {
|
|
81
|
-
const parts = clause.any.map((c) => evalClause(c, ctx))
|
|
81
|
+
const parts = clause.any.map((c) => withLabel(c, evalClause(c, ctx)))
|
|
82
|
+
const pass = parts.some((p) => p.pass)
|
|
82
83
|
return {
|
|
83
|
-
pass
|
|
84
|
-
error: parts.find((p) => p.error)?.error || null,
|
|
84
|
+
pass,
|
|
85
|
+
error: pass ? null : (parts.find((p) => p.error)?.error || null),
|
|
85
86
|
actual: parts,
|
|
86
87
|
clause: { any: true }
|
|
87
88
|
}
|
|
@@ -131,6 +132,18 @@ function evalClause (clause, ctx) {
|
|
|
131
132
|
return { pass: false, error: 'clause needs path, helper, zone, or run', actual: null }
|
|
132
133
|
}
|
|
133
134
|
const actual = getByPath(ctx.values, path)
|
|
135
|
+
const needsValue = ['is', 'equals', 'above', 'below', 'to'].some((k) =>
|
|
136
|
+
Object.prototype.hasOwnProperty.call(clause, k)
|
|
137
|
+
)
|
|
138
|
+
if (needsValue && (actual === undefined || actual === null)) {
|
|
139
|
+
return {
|
|
140
|
+
pass: false,
|
|
141
|
+
error: 'missing',
|
|
142
|
+
actual,
|
|
143
|
+
path,
|
|
144
|
+
expected: expectedOf(clause)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
134
147
|
let pass = false
|
|
135
148
|
if (Object.prototype.hasOwnProperty.call(clause, 'is')) {
|
|
136
149
|
pass = actual === clause.is
|
|
@@ -228,10 +241,20 @@ function clauseLabel (clause, result) {
|
|
|
228
241
|
|
|
229
242
|
function markPass (r) {
|
|
230
243
|
if (!r) return '✗'
|
|
231
|
-
if (r.error) return '✗'
|
|
244
|
+
if (r.error && !r.pass) return '✗'
|
|
232
245
|
return r.pass ? '✓' : '✗'
|
|
233
246
|
}
|
|
234
247
|
|
|
248
|
+
function appendClauseLines (lines, r, indent) {
|
|
249
|
+
if (!r) return
|
|
250
|
+
lines.push(indent + markPass(r) + ' ' + (r.label || ''))
|
|
251
|
+
const inner = r.clause && (r.clause.any || r.clause.all)
|
|
252
|
+
if (!inner || !Array.isArray(r.actual)) return
|
|
253
|
+
for (const part of r.actual) {
|
|
254
|
+
appendClauseLines(lines, part, indent + ' ')
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
235
258
|
function withLabel (clause, result) {
|
|
236
259
|
const row = result && typeof result === 'object' ? result : { pass: false }
|
|
237
260
|
row.clause = clause
|
|
@@ -279,9 +302,13 @@ async function evalListAll (list, ctx) {
|
|
|
279
302
|
return { pass, results }
|
|
280
303
|
}
|
|
281
304
|
|
|
305
|
+
function resultLabel (result) {
|
|
306
|
+
return result === 'failure' ? 'Failed' : 'OK'
|
|
307
|
+
}
|
|
308
|
+
|
|
282
309
|
function formatVerboseLog (auto, record) {
|
|
283
310
|
const name = (auto && (auto.alias || auto.id)) || (record && record.id) || 'automation'
|
|
284
|
-
const result = record.result
|
|
311
|
+
const result = resultLabel(record.result)
|
|
285
312
|
const head = name + ' → ' + result + (record.reason ? ' (' + record.reason + ')' : '')
|
|
286
313
|
const lines = [head]
|
|
287
314
|
if (record.firedBy) lines.push(' trigger: ' + record.firedBy)
|
|
@@ -297,7 +324,7 @@ function formatVerboseLog (auto, record) {
|
|
|
297
324
|
for (const b of record.branches || []) {
|
|
298
325
|
lines.push(' ' + (b.picked ? '→ ' : ' ') + b.alias + (b.picked ? '' : ''))
|
|
299
326
|
for (const c of b.conditions || []) {
|
|
300
|
-
lines
|
|
327
|
+
appendClauseLines(lines, c, ' ')
|
|
301
328
|
}
|
|
302
329
|
}
|
|
303
330
|
return lines.join('\n')
|
|
@@ -392,7 +419,7 @@ async function evaluateAutomation (auto, ctx) {
|
|
|
392
419
|
const record = {
|
|
393
420
|
ts: new Date(ctx.now || Date.now()).toISOString(),
|
|
394
421
|
id: auto.id,
|
|
395
|
-
result: '
|
|
422
|
+
result: 'ok',
|
|
396
423
|
trigger: ctx.trigger || null,
|
|
397
424
|
firedBy: triggerFiredLabel(ctx.trigger),
|
|
398
425
|
conditions: [],
|
|
@@ -416,7 +443,7 @@ async function evaluateAutomation (auto, ctx) {
|
|
|
416
443
|
record.conditions = cond.results
|
|
417
444
|
if ((auto.condition || []).length && !cond.pass) {
|
|
418
445
|
const failed = cond.results.find((r) => r.error)
|
|
419
|
-
record.result = failed ? 'failure' : '
|
|
446
|
+
record.result = failed ? 'failure' : 'ok'
|
|
420
447
|
record.reason = failed ? failed.error : 'condition not met'
|
|
421
448
|
return finishRecord(auto, record)
|
|
422
449
|
}
|
|
@@ -441,6 +468,7 @@ async function evaluateAutomation (auto, ctx) {
|
|
|
441
468
|
record.branches.push(row)
|
|
442
469
|
}
|
|
443
470
|
if (!picked) {
|
|
471
|
+
record.result = 'ok'
|
|
444
472
|
record.reason = 'no matching choose branch'
|
|
445
473
|
return finishRecord(auto, record)
|
|
446
474
|
}
|
package/lib/runtime.js
CHANGED
|
@@ -135,17 +135,24 @@ class Runtime {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
async handlePathChange (p, value, source) {
|
|
138
|
+
const previous = Object.prototype.hasOwnProperty.call(this.values, p)
|
|
139
|
+
? this.values[p]
|
|
140
|
+
: undefined
|
|
138
141
|
this.setPathValue(p, value, { $source: source })
|
|
139
|
-
await this.applyLatches(p, source)
|
|
142
|
+
await this.applyLatches(p, source, previous)
|
|
140
143
|
await this.kickPath(p, value)
|
|
141
144
|
}
|
|
142
145
|
|
|
143
|
-
async applyLatches (p, source) {
|
|
146
|
+
async applyLatches (p, source, previous) {
|
|
144
147
|
const fromSelf = source && String(source).includes(this.pluginId)
|
|
145
148
|
if (fromSelf) return
|
|
149
|
+
if (previous === undefined) return
|
|
150
|
+
const next = this.values[p]
|
|
151
|
+
if (engine.sameValue(previous, next)) return
|
|
152
|
+
if (asSwitch(previous) !== null && asSwitch(previous) === asSwitch(next)) return
|
|
146
153
|
for (const def of Object.values(this.doc.helpers)) {
|
|
147
154
|
if (def.latch_on_external_put === p && def.type === 'boolean') {
|
|
148
|
-
|
|
155
|
+
await this.setHelper(def.id, true)
|
|
149
156
|
}
|
|
150
157
|
}
|
|
151
158
|
}
|
|
@@ -439,6 +446,12 @@ class Runtime {
|
|
|
439
446
|
}
|
|
440
447
|
}
|
|
441
448
|
|
|
449
|
+
function asSwitch (v) {
|
|
450
|
+
if (v === true || v === 1 || v === '1' || v === 'on' || v === 'ON') return 1
|
|
451
|
+
if (v === false || v === 0 || v === '0' || v === 'off' || v === 'OFF') return 0
|
|
452
|
+
return null
|
|
453
|
+
}
|
|
454
|
+
|
|
442
455
|
function holdMs (auto) {
|
|
443
456
|
for (const t of auto.trigger || []) {
|
|
444
457
|
if (t.for) {
|
package/package.json
CHANGED
package/plugin/index.js
CHANGED
|
@@ -305,15 +305,36 @@ module.exports = function (app) {
|
|
|
305
305
|
})
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
-
function
|
|
309
|
-
return
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
308
|
+
function isMultipleSources (err) {
|
|
309
|
+
return /multiple sources for the given path/i.test(String((err && err.message) || err || ''))
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function putSources (p) {
|
|
313
|
+
const out = []
|
|
314
|
+
const seen = Object.create(null)
|
|
315
|
+
const add = (s) => {
|
|
316
|
+
if (!s) return
|
|
317
|
+
const id = String(s)
|
|
318
|
+
if (seen[id] || id.includes(PLUGIN_ID)) return
|
|
319
|
+
seen[id] = true
|
|
320
|
+
out.push(id)
|
|
321
|
+
}
|
|
322
|
+
const node = typeof app.getSelfPath === 'function' ? app.getSelfPath(p) : null
|
|
323
|
+
if (node && typeof node === 'object' && !Array.isArray(node)) {
|
|
324
|
+
add(node.$source)
|
|
325
|
+
const vals = node.values
|
|
326
|
+
if (vals && typeof vals === 'object') {
|
|
327
|
+
Object.keys(vals)
|
|
328
|
+
.sort((a, b) => Number(/^mqtt\b/i.test(b)) - Number(/^mqtt\b/i.test(a)))
|
|
329
|
+
.forEach(add)
|
|
316
330
|
}
|
|
331
|
+
}
|
|
332
|
+
if (runtime && runtime.values) add(runtime.values[p + '.$source'])
|
|
333
|
+
return out
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function callPutSelf (p, value, source) {
|
|
337
|
+
return new Promise((resolve, reject) => {
|
|
317
338
|
let settled = false
|
|
318
339
|
const finish = (err) => {
|
|
319
340
|
if (settled) return
|
|
@@ -332,7 +353,9 @@ module.exports = function (app) {
|
|
|
332
353
|
finish(null)
|
|
333
354
|
}
|
|
334
355
|
try {
|
|
335
|
-
const ret =
|
|
356
|
+
const ret = source
|
|
357
|
+
? app.putSelfPath(p, value, fromReply, source)
|
|
358
|
+
: app.putSelfPath(p, value, fromReply)
|
|
336
359
|
if (ret && typeof ret.then === 'function') {
|
|
337
360
|
ret.then(fromReply, finish)
|
|
338
361
|
}
|
|
@@ -342,6 +365,30 @@ module.exports = function (app) {
|
|
|
342
365
|
})
|
|
343
366
|
}
|
|
344
367
|
|
|
368
|
+
async function putPath (p, value) {
|
|
369
|
+
if (typeof app.putSelfPath !== 'function') {
|
|
370
|
+
app.handleMessage(PLUGIN_ID, {
|
|
371
|
+
updates: [{ values: [{ path: p, value }] }]
|
|
372
|
+
})
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
await callPutSelf(p, value)
|
|
377
|
+
} catch (err) {
|
|
378
|
+
if (!isMultipleSources(err)) throw err
|
|
379
|
+
let last = err
|
|
380
|
+
for (const source of putSources(p)) {
|
|
381
|
+
try {
|
|
382
|
+
await callPutSelf(p, value, source)
|
|
383
|
+
return
|
|
384
|
+
} catch (e) {
|
|
385
|
+
last = e
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
throw last
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
345
392
|
function notify (p, state, message) {
|
|
346
393
|
const npath = String(p).startsWith('notifications.') ? p : 'notifications.' + p
|
|
347
394
|
app.handleMessage(PLUGIN_ID, {
|
package/public/app.js
CHANGED
|
@@ -73,9 +73,15 @@ async function checkLogin () {
|
|
|
73
73
|
|
|
74
74
|
function el (id) { return document.getElementById(id) }
|
|
75
75
|
|
|
76
|
+
function displayResult (result) {
|
|
77
|
+
if (!result) return '—'
|
|
78
|
+
if (result === 'failure') return 'Failed'
|
|
79
|
+
return 'OK'
|
|
80
|
+
}
|
|
81
|
+
|
|
76
82
|
function badge (result) {
|
|
77
|
-
const
|
|
78
|
-
return '<span class="badge ' +
|
|
83
|
+
const klass = !result ? '' : result === 'failure' ? 'failure' : 'ok'
|
|
84
|
+
return '<span class="badge ' + klass + '">' + displayResult(result) + '</span>'
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
function fmtVal (v) {
|
|
@@ -136,15 +142,18 @@ function fmtRunRecord (t) {
|
|
|
136
142
|
function fmtTime (iso) {
|
|
137
143
|
if (!iso) return ''
|
|
138
144
|
try {
|
|
139
|
-
|
|
145
|
+
const d = new Date(iso)
|
|
146
|
+
if (Number.isNaN(d.getTime())) return iso
|
|
147
|
+
return new Intl.DateTimeFormat('nl-NL', {
|
|
148
|
+
timeZone: 'Europe/Amsterdam',
|
|
140
149
|
year: 'numeric',
|
|
141
150
|
month: '2-digit',
|
|
142
151
|
day: '2-digit',
|
|
143
152
|
hour: '2-digit',
|
|
144
153
|
minute: '2-digit',
|
|
145
154
|
second: '2-digit',
|
|
146
|
-
|
|
147
|
-
})
|
|
155
|
+
hourCycle: 'h23'
|
|
156
|
+
}).format(d)
|
|
148
157
|
} catch (_) { return iso }
|
|
149
158
|
}
|
|
150
159
|
|
package/public/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>Signal K Automations</title>
|
|
7
7
|
<link rel="icon" href="icon.png" />
|
|
8
|
-
<link rel="stylesheet" href="style.css?v=
|
|
8
|
+
<link rel="stylesheet" href="style.css?v=20260918ok" />
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<header>
|
|
@@ -61,6 +61,6 @@
|
|
|
61
61
|
</div>
|
|
62
62
|
</section>
|
|
63
63
|
</main>
|
|
64
|
-
<script src="app.js?v=
|
|
64
|
+
<script src="app.js?v=20260918ok"></script>
|
|
65
65
|
</body>
|
|
66
66
|
</html>
|