node-red-dashboard-2-hmi 0.1.10 → 0.1.11

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/nodes/values.js CHANGED
@@ -1,176 +1,176 @@
1
- 'use strict'
2
-
3
- // Merges an incoming msg into the canvas values.
4
- // - object payload: every key is merged (e.g. the output of schneider read)
5
- // - any other payload with msg.topic: stored under that topic
6
- // Returns true when something changed.
7
- function mergeValues (values, msg) {
8
- const p = msg && msg.payload
9
- if (p !== null && typeof p === 'object' && !Array.isArray(p) && !Buffer.isBuffer(p)) {
10
- Object.assign(values, p)
11
- return Object.keys(p).length > 0
12
- }
13
- if (msg && typeof msg.topic === 'string' && msg.topic !== '' && p !== undefined) {
14
- values[msg.topic] = p
15
- return true
16
- }
17
- return false
18
- }
19
-
20
- // Text typed in the editor -> number, boolean or string
21
- function parseValue (text) {
22
- if (text === undefined || text === null || text === '') return undefined
23
- if (text === 'true') return true
24
- if (text === 'false') return false
25
- const n = Number(String(text).replace(',', '.'))
26
- return isFinite(n) ? n : String(text)
27
- }
28
-
29
- const truthy = v => v === true || (v !== false && v !== null && v !== undefined && v !== '' && v !== 'false' && Number(v) !== 0)
30
-
31
- const num = v => (v === '' || v === undefined || v === null || typeof v === 'boolean' || !isFinite(Number(v))) ? null : Number(v)
32
-
33
- // raw PLC value -> value on screen (raw × factor ÷ divisor + offset), and back
34
- function scaleOf (sym) {
35
- const sc = sym.scale || {}
36
- const d = num(sc.divisor)
37
- const k = (num(sc.factor) === null ? 1 : num(sc.factor)) / (d === null || d === 0 ? 1 : d)
38
- const o = num(sc.offset) === null ? 0 : num(sc.offset)
39
- return { k, o }
40
- }
41
- const toScreen = (sym, raw) => { const n = num(raw); if (n === null) return null; const { k, o } = scaleOf(sym); return Math.round((n * k + o) * 1e9) / 1e9 }
42
- const toRaw = (sym, shown) => { const { k, o } = scaleOf(sym); return k !== 0 && (k !== 1 || o !== 0) ? Math.round(((shown - o) / k) * 1e9) / 1e9 : shown }
43
-
44
- // Interlocks of an action: "permissivo, !emergencia" -> every key must be on
45
- // (or off with "!"). Returns the reason when the command is blocked, else ''.
46
- function interlockOf (act, values) {
47
- const list = String((act && act.interlock) || '').split(',').map(t => t.trim()).filter(Boolean)
48
- for (const item of list) {
49
- const not = item.startsWith('!')
50
- const key = not ? item.slice(1).trim() : item
51
- if (!key) continue
52
- const on = truthy(values[key])
53
- if (not ? on : !on) return String(act.interlockText || '').trim() || ((not ? key + ' ligado' : key + ' desligado'))
54
- }
55
- return ''
56
- }
57
-
58
- function inRange (n, min, max) {
59
- const lo = num(min)
60
- const hi = num(max)
61
- return !((lo !== null && n < lo) || (hi !== null && n > hi))
62
- }
63
- const rangeText = (min, max) => num(min) !== null && num(max) !== null ? ' (' + num(min) + ' a ' + num(max) + ')' : ''
64
-
65
- // Builds the output msg for a click on a symbol. The dashboard only says which
66
- // symbol and which phase (click, down, up, input...); what is sent comes from the
67
- // node's own config, so a page cannot make the node send arbitrary values.
68
- // Returns null when nothing should be sent, or { denied | blocked | rejected | armed }.
69
- // opts.pin: the canvas password (node credential); actions marked "pedir senha" need it.
70
- // opts.armed: a pulse whose password was already given (phase "arm").
71
- function actionMessage (symbols, values, msg, pinOrOpts) {
72
- const opts = pinOrOpts && typeof pinOrOpts === 'object' ? pinOrOpts : { pin: pinOrOpts }
73
- const pin = opts.pin
74
- const a = (msg && msg._hmiAction) || {}
75
- const sym = (Array.isArray(symbols) ? symbols : []).find(s => s && s.id === a.id)
76
- const act = sym && sym.action
77
- if (!act) return null
78
- const needPin = act.pin === 'yes'
79
- const pinOk = () => !!pin && String(a.pin || '') === String(pin)
80
- if (a.phase === 'arm') {
81
- if (act.mode !== 'pulse' || !needPin) return null
82
- return pinOk() ? { armed: true } : { denied: true }
83
- }
84
- // releasing a pulse is always allowed: it only sends the 0
85
- const release = act.mode === 'pulse' && a.phase === 'up'
86
- if (needPin && !release && !(act.mode === 'pulse' && opts.armed) && !pinOk()) return { denied: true }
87
- if (!release) {
88
- const why = interlockOf(act, values)
89
- if (why) return { blocked: why }
90
- }
91
- const cur = sym.key ? values[sym.key] : undefined
92
- const asBool = typeof cur === 'boolean'
93
- let payload
94
- let topic = act.topic || sym.key || ''
95
- const fp = act.mode === 'faceplate' ? String(a.phase || '') : ''
96
- if (fp === 'fp-on' || fp === 'fp-off' || fp === 'fp-reset' || fp === 'fp-auto' || fp === 'fp-man') {
97
- // faceplate buttons: topic and value come from the symbol's config
98
- const k = fp.slice(3)
99
- const tk = k === 'auto' || k === 'man' ? 'modeTopic' : k + 'Topic'
100
- if (!act[tk]) return null
101
- topic = act[tk]
102
- payload = parseValue(act[k + 'Value'])
103
- if (payload === undefined) payload = k === 'man' ? 0 : (k === 'auto' ? 1 : true)
104
- } else if (fp === 'fp-sp' || fp === 'fp-op') {
105
- // faceplate setpoint / manual output typed on screen
106
- const k = fp.slice(3)
107
- if (!act[k + 'Topic']) return null
108
- topic = act[k + 'Topic']
109
- payload = Number(String(a.value).replace(',', '.'))
110
- if (String(a.value ?? '').trim() === '' || !isFinite(payload)) return null
111
- const min = act[k + 'Min'] ?? (k === 'op' ? '0' : '')
112
- const max = act[k + 'Max'] ?? (k === 'op' ? '100' : '')
113
- if (!inRange(payload, min, max)) return { rejected: 'Valor fora da faixa' + rangeText(min, max) }
114
- } else if (act.mode === 'value' && a.phase === 'click') {
115
- payload = parseValue(act.value)
116
- if (payload === undefined) payload = true
117
- } else if (act.mode === 'toggle' && a.phase === 'click') {
118
- const on = truthy(cur)
119
- payload = asBool ? !on : (on ? 0 : 1)
120
- } else if (act.mode === 'pulse' && (a.phase === 'down' || a.phase === 'up')) {
121
- const down = a.phase === 'down'
122
- payload = asBool ? down : (down ? 1 : 0)
123
- } else if (act.mode === 'input' && a.phase === 'input') {
124
- payload = Number(String(a.value).replace(',', '.'))
125
- if (String(a.value ?? '').trim() === '' || !isFinite(payload)) return null
126
- if (!inRange(payload, act.min, act.max)) return { rejected: 'Valor fora da faixa' + rangeText(act.min, act.max) }
127
- // biggest change in one step, in the units shown on screen
128
- const step = num(act.maxStep)
129
- const shown = sym.key ? toScreen(sym, cur) : null
130
- if (step !== null && step > 0 && shown !== null && Math.abs(payload - shown) > step) return { rejected: 'Mudança maior que ' + step + ' de uma vez' }
131
- // limits are in the units shown on screen; the PLC gets the raw value
132
- payload = toRaw(sym, payload)
133
- } else {
134
- return null
135
- }
136
- const p = sym.props || {}
137
- const name = String(p.label || p.title || p.text || sym.key || sym.type)
138
- const user = userOf(msg)
139
- // who sent what and when, for a command log (database, file...)
140
- const audit = { ts: new Date().toISOString(), symbol: name, action: a.phase === 'click' || a.phase === 'input' ? act.mode : a.phase, topic, value: payload }
141
- if (user) audit.user = user
142
- if (msg._client && msg._client.socketId) audit.socketId = msg._client.socketId
143
- const out = { topic, payload, symbol: { id: sym.id, type: sym.type, key: sym.key || '' }, audit }
144
- if (msg._client) out._client = msg._client
145
- return out
146
- }
147
-
148
- // What the PLC should echo back on the symbol's key after a command (for the
149
- // "waiting for the PLC" mark), or undefined when it cannot be told.
150
- function expectedEcho (sym, out, phase) {
151
- if (!sym || !sym.key || !out || !sym.action) return undefined
152
- const m = sym.action.mode
153
- if (m === 'toggle' || m === 'input') return out.payload
154
- if (m === 'value' && (!sym.action.topic || sym.action.topic === sym.key)) return out.payload
155
- if (m === 'faceplate' && (phase === 'fp-on' || phase === 'fp-off')) return phase === 'fp-on'
156
- return undefined
157
- }
158
-
159
- // does the value read back match what was written? (1 == true, "5" == 5)
160
- function sameValue (a, b) {
161
- if (typeof b === 'boolean' || typeof a === 'boolean') return truthy(a) === truthy(b)
162
- const x = num(a)
163
- const y = num(b)
164
- if (x !== null && y !== null) return Math.abs(x - y) < 1e-9 * Math.max(1, Math.abs(y))
165
- return String(a) === String(b)
166
- }
167
-
168
- // user name from the dashboard connection (FlowFuse / multi-user plugins), if any
169
- function userOf (msg) {
170
- const u = msg && msg._client && msg._client.user
171
- if (!u) return ''
172
- if (typeof u === 'string') return u
173
- return String(u.name || u.username || u.email || u.userId || u.id || '')
174
- }
175
-
176
- module.exports = { mergeValues, parseValue, actionMessage, userOf, interlockOf, expectedEcho, sameValue, truthy }
1
+ 'use strict'
2
+
3
+ // Merges an incoming msg into the canvas values.
4
+ // - object payload: every key is merged (e.g. the output of schneider read)
5
+ // - any other payload with msg.topic: stored under that topic
6
+ // Returns true when something changed.
7
+ function mergeValues (values, msg) {
8
+ const p = msg && msg.payload
9
+ if (p !== null && typeof p === 'object' && !Array.isArray(p) && !Buffer.isBuffer(p)) {
10
+ Object.assign(values, p)
11
+ return Object.keys(p).length > 0
12
+ }
13
+ if (msg && typeof msg.topic === 'string' && msg.topic !== '' && p !== undefined) {
14
+ values[msg.topic] = p
15
+ return true
16
+ }
17
+ return false
18
+ }
19
+
20
+ // Text typed in the editor -> number, boolean or string
21
+ function parseValue (text) {
22
+ if (text === undefined || text === null || text === '') return undefined
23
+ if (text === 'true') return true
24
+ if (text === 'false') return false
25
+ const n = Number(String(text).replace(',', '.'))
26
+ return isFinite(n) ? n : String(text)
27
+ }
28
+
29
+ const truthy = v => v === true || (v !== false && v !== null && v !== undefined && v !== '' && v !== 'false' && Number(v) !== 0)
30
+
31
+ const num = v => (v === '' || v === undefined || v === null || typeof v === 'boolean' || !isFinite(Number(v))) ? null : Number(v)
32
+
33
+ // raw PLC value -> value on screen (raw × factor ÷ divisor + offset), and back
34
+ function scaleOf (sym) {
35
+ const sc = sym.scale || {}
36
+ const d = num(sc.divisor)
37
+ const k = (num(sc.factor) === null ? 1 : num(sc.factor)) / (d === null || d === 0 ? 1 : d)
38
+ const o = num(sc.offset) === null ? 0 : num(sc.offset)
39
+ return { k, o }
40
+ }
41
+ const toScreen = (sym, raw) => { const n = num(raw); if (n === null) return null; const { k, o } = scaleOf(sym); return Math.round((n * k + o) * 1e9) / 1e9 }
42
+ const toRaw = (sym, shown) => { const { k, o } = scaleOf(sym); return k !== 0 && (k !== 1 || o !== 0) ? Math.round(((shown - o) / k) * 1e9) / 1e9 : shown }
43
+
44
+ // Interlocks of an action: "permissivo, !emergencia" -> every key must be on
45
+ // (or off with "!"). Returns the reason when the command is blocked, else ''.
46
+ function interlockOf (act, values) {
47
+ const list = String((act && act.interlock) || '').split(',').map(t => t.trim()).filter(Boolean)
48
+ for (const item of list) {
49
+ const not = item.startsWith('!')
50
+ const key = not ? item.slice(1).trim() : item
51
+ if (!key) continue
52
+ const on = truthy(values[key])
53
+ if (not ? on : !on) return String(act.interlockText || '').trim() || ((not ? key + ' ligado' : key + ' desligado'))
54
+ }
55
+ return ''
56
+ }
57
+
58
+ function inRange (n, min, max) {
59
+ const lo = num(min)
60
+ const hi = num(max)
61
+ return !((lo !== null && n < lo) || (hi !== null && n > hi))
62
+ }
63
+ const rangeText = (min, max) => num(min) !== null && num(max) !== null ? ' (' + num(min) + ' a ' + num(max) + ')' : ''
64
+
65
+ // Builds the output msg for a click on a symbol. The dashboard only says which
66
+ // symbol and which phase (click, down, up, input...); what is sent comes from the
67
+ // node's own config, so a page cannot make the node send arbitrary values.
68
+ // Returns null when nothing should be sent, or { denied | blocked | rejected | armed }.
69
+ // opts.pin: the canvas password (node credential); actions marked "pedir senha" need it.
70
+ // opts.armed: a pulse whose password was already given (phase "arm").
71
+ function actionMessage (symbols, values, msg, pinOrOpts) {
72
+ const opts = pinOrOpts && typeof pinOrOpts === 'object' ? pinOrOpts : { pin: pinOrOpts }
73
+ const pin = opts.pin
74
+ const a = (msg && msg._hmiAction) || {}
75
+ const sym = (Array.isArray(symbols) ? symbols : []).find(s => s && s.id === a.id)
76
+ const act = sym && sym.action
77
+ if (!act) return null
78
+ const needPin = act.pin === 'yes'
79
+ const pinOk = () => !!pin && String(a.pin || '') === String(pin)
80
+ if (a.phase === 'arm') {
81
+ if (act.mode !== 'pulse' || !needPin) return null
82
+ return pinOk() ? { armed: true } : { denied: true }
83
+ }
84
+ // releasing a pulse is always allowed: it only sends the 0
85
+ const release = act.mode === 'pulse' && a.phase === 'up'
86
+ if (needPin && !release && !(act.mode === 'pulse' && opts.armed) && !pinOk()) return { denied: true }
87
+ if (!release) {
88
+ const why = interlockOf(act, values)
89
+ if (why) return { blocked: why }
90
+ }
91
+ const cur = sym.key ? values[sym.key] : undefined
92
+ const asBool = typeof cur === 'boolean'
93
+ let payload
94
+ let topic = act.topic || sym.key || ''
95
+ const fp = act.mode === 'faceplate' ? String(a.phase || '') : ''
96
+ if (fp === 'fp-on' || fp === 'fp-off' || fp === 'fp-reset' || fp === 'fp-auto' || fp === 'fp-man') {
97
+ // faceplate buttons: topic and value come from the symbol's config
98
+ const k = fp.slice(3)
99
+ const tk = k === 'auto' || k === 'man' ? 'modeTopic' : k + 'Topic'
100
+ if (!act[tk]) return null
101
+ topic = act[tk]
102
+ payload = parseValue(act[k + 'Value'])
103
+ if (payload === undefined) payload = k === 'man' ? 0 : (k === 'auto' ? 1 : true)
104
+ } else if (fp === 'fp-sp' || fp === 'fp-op') {
105
+ // faceplate setpoint / manual output typed on screen
106
+ const k = fp.slice(3)
107
+ if (!act[k + 'Topic']) return null
108
+ topic = act[k + 'Topic']
109
+ payload = Number(String(a.value).replace(',', '.'))
110
+ if (String(a.value ?? '').trim() === '' || !isFinite(payload)) return null
111
+ const min = act[k + 'Min'] ?? (k === 'op' ? '0' : '')
112
+ const max = act[k + 'Max'] ?? (k === 'op' ? '100' : '')
113
+ if (!inRange(payload, min, max)) return { rejected: 'Valor fora da faixa' + rangeText(min, max) }
114
+ } else if (act.mode === 'value' && a.phase === 'click') {
115
+ payload = parseValue(act.value)
116
+ if (payload === undefined) payload = true
117
+ } else if (act.mode === 'toggle' && a.phase === 'click') {
118
+ const on = truthy(cur)
119
+ payload = asBool ? !on : (on ? 0 : 1)
120
+ } else if (act.mode === 'pulse' && (a.phase === 'down' || a.phase === 'up')) {
121
+ const down = a.phase === 'down'
122
+ payload = asBool ? down : (down ? 1 : 0)
123
+ } else if (act.mode === 'input' && a.phase === 'input') {
124
+ payload = Number(String(a.value).replace(',', '.'))
125
+ if (String(a.value ?? '').trim() === '' || !isFinite(payload)) return null
126
+ if (!inRange(payload, act.min, act.max)) return { rejected: 'Valor fora da faixa' + rangeText(act.min, act.max) }
127
+ // biggest change in one step, in the units shown on screen
128
+ const step = num(act.maxStep)
129
+ const shown = sym.key ? toScreen(sym, cur) : null
130
+ if (step !== null && step > 0 && shown !== null && Math.abs(payload - shown) > step) return { rejected: 'Mudança maior que ' + step + ' de uma vez' }
131
+ // limits are in the units shown on screen; the PLC gets the raw value
132
+ payload = toRaw(sym, payload)
133
+ } else {
134
+ return null
135
+ }
136
+ const p = sym.props || {}
137
+ const name = String(p.label || p.title || p.text || sym.key || sym.type)
138
+ const user = userOf(msg)
139
+ // who sent what and when, for a command log (database, file...)
140
+ const audit = { ts: new Date().toISOString(), symbol: name, action: a.phase === 'click' || a.phase === 'input' ? act.mode : a.phase, topic, value: payload }
141
+ if (user) audit.user = user
142
+ if (msg._client && msg._client.socketId) audit.socketId = msg._client.socketId
143
+ const out = { topic, payload, symbol: { id: sym.id, type: sym.type, key: sym.key || '' }, audit }
144
+ if (msg._client) out._client = msg._client
145
+ return out
146
+ }
147
+
148
+ // What the PLC should echo back on the symbol's key after a command (for the
149
+ // "waiting for the PLC" mark), or undefined when it cannot be told.
150
+ function expectedEcho (sym, out, phase) {
151
+ if (!sym || !sym.key || !out || !sym.action) return undefined
152
+ const m = sym.action.mode
153
+ if (m === 'toggle' || m === 'input') return out.payload
154
+ if (m === 'value' && (!sym.action.topic || sym.action.topic === sym.key)) return out.payload
155
+ if (m === 'faceplate' && (phase === 'fp-on' || phase === 'fp-off')) return phase === 'fp-on'
156
+ return undefined
157
+ }
158
+
159
+ // does the value read back match what was written? (1 == true, "5" == 5)
160
+ function sameValue (a, b) {
161
+ if (typeof b === 'boolean' || typeof a === 'boolean') return truthy(a) === truthy(b)
162
+ const x = num(a)
163
+ const y = num(b)
164
+ if (x !== null && y !== null) return Math.abs(x - y) < 1e-9 * Math.max(1, Math.abs(y))
165
+ return String(a) === String(b)
166
+ }
167
+
168
+ // user name from the dashboard connection (FlowFuse / multi-user plugins), if any
169
+ function userOf (msg) {
170
+ const u = msg && msg._client && msg._client.user
171
+ if (!u) return ''
172
+ if (typeof u === 'string') return u
173
+ return String(u.name || u.username || u.email || u.userId || u.id || '')
174
+ }
175
+
176
+ module.exports = { mergeValues, parseValue, actionMessage, userOf, interlockOf, expectedEcho, sameValue, truthy }
package/package.json CHANGED
@@ -1,63 +1,63 @@
1
- {
2
- "name": "node-red-dashboard-2-hmi",
3
- "version": "0.1.10",
4
- "description": "Build machine HMI screens for Node-RED Dashboard 2.0 by dragging symbols onto a canvas (ISA-101 and classic themes)",
5
- "keywords": [
6
- "node-red",
7
- "node-red-dashboard-2",
8
- "hmi",
9
- "scada",
10
- "isa-101",
11
- "synoptic",
12
- "svg"
13
- ],
14
- "homepage": "https://github.com/Eduardo-556/node-red-dashboard-2-hmi#readme",
15
- "bugs": {
16
- "url": "https://github.com/Eduardo-556/node-red-dashboard-2-hmi/issues"
17
- },
18
- "repository": {
19
- "type": "git",
20
- "url": "git+https://github.com/Eduardo-556/node-red-dashboard-2-hmi.git"
21
- },
22
- "license": "MIT",
23
- "author": "Eduardo",
24
- "files": [
25
- "nodes",
26
- "resources",
27
- "examples",
28
- "README.md",
29
- "LICENSE",
30
- "CHANGELOG.md",
31
- "docs/mensagens.md"
32
- ],
33
- "scripts": {
34
- "build": "vite build && vite build --config vite.editor.config.mjs",
35
- "test": "node --test test/symbols.test.mjs test/canvas.test.js test/server.test.js"
36
- },
37
- "devDependencies": {
38
- "@vitejs/plugin-vue": "^6.0.0",
39
- "vite": "^8.0.0",
40
- "vue": "^3.5.0"
41
- },
42
- "engines": {
43
- "node": ">=18"
44
- },
45
- "node-red": {
46
- "version": ">=3.0.0",
47
- "nodes": {
48
- "ui-hmi-canvas": "nodes/ui-hmi-canvas.js"
49
- },
50
- "plugins": {
51
- "hmi-canvas-actions": "nodes/plugin.js"
52
- }
53
- },
54
- "node-red-dashboard-2": {
55
- "version": "1.0.0",
56
- "widgets": {
57
- "ui-hmi-canvas": {
58
- "output": "ui-hmi-canvas.umd.js",
59
- "component": "UIHmiCanvas"
60
- }
61
- }
62
- }
63
- }
1
+ {
2
+ "name": "node-red-dashboard-2-hmi",
3
+ "version": "0.1.11",
4
+ "description": "Build machine HMI screens for Node-RED Dashboard 2.0 by dragging symbols onto a canvas (ISA-101 and classic themes)",
5
+ "keywords": [
6
+ "node-red",
7
+ "node-red-dashboard-2",
8
+ "hmi",
9
+ "scada",
10
+ "isa-101",
11
+ "synoptic",
12
+ "svg"
13
+ ],
14
+ "homepage": "https://github.com/Eduardo-556/node-red-dashboard-2-hmi#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/Eduardo-556/node-red-dashboard-2-hmi/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/Eduardo-556/node-red-dashboard-2-hmi.git"
21
+ },
22
+ "license": "MIT",
23
+ "author": "Eduardo",
24
+ "files": [
25
+ "nodes",
26
+ "resources",
27
+ "examples",
28
+ "README.md",
29
+ "LICENSE",
30
+ "CHANGELOG.md",
31
+ "docs/mensagens.md"
32
+ ],
33
+ "scripts": {
34
+ "build": "vite build && vite build --config vite.editor.config.mjs",
35
+ "test": "node --test test/symbols.test.mjs test/canvas.test.js test/server.test.js"
36
+ },
37
+ "devDependencies": {
38
+ "@vitejs/plugin-vue": "^6.0.0",
39
+ "vite": "^8.0.0",
40
+ "vue": "^3.5.0"
41
+ },
42
+ "engines": {
43
+ "node": ">=18"
44
+ },
45
+ "node-red": {
46
+ "version": ">=3.0.0",
47
+ "nodes": {
48
+ "ui-hmi-canvas": "nodes/ui-hmi-canvas.js"
49
+ },
50
+ "plugins": {
51
+ "hmi-canvas-actions": "nodes/plugin.js"
52
+ }
53
+ },
54
+ "node-red-dashboard-2": {
55
+ "version": "1.0.0",
56
+ "widgets": {
57
+ "ui-hmi-canvas": {
58
+ "output": "ui-hmi-canvas.umd.js",
59
+ "component": "UIHmiCanvas"
60
+ }
61
+ }
62
+ }
63
+ }