node-red-contrib-room-tools 1.8.9 → 1.8.16

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/meter-frame.js ADDED
@@ -0,0 +1,111 @@
1
+ // Shared, non-reactive receivers; usable in Node and injected Vue component methods.
2
+ function createMeterReceiver (manifest) {
3
+ const number = value => typeof value === 'number' && Number.isFinite(value)
4
+ if (manifest?.kind !== 'meter-manifest' || typeof manifest.streamId !== 'string' ||
5
+ !manifest.streamId.trim() || manifest.streamId.length > 255 ||
6
+ typeof manifest.epoch !== 'string' || !manifest.epoch || manifest.epoch.length > 128 ||
7
+ !Number.isInteger(manifest.layoutVersion) || manifest.layoutVersion < 1 ||
8
+ manifest.layoutVersion > 2147483647 || manifest.clock !== 'monotonic-ms' ||
9
+ !number(manifest.publishHz) || manifest.publishHz < 1 || manifest.publishHz > 60 ||
10
+ !Array.isArray(manifest.points) || !manifest.points.length || manifest.points.length > 256) {
11
+ throw Error('Invalid meter manifest')
12
+ }
13
+ const paths = new Set()
14
+ const points = Array.from(manifest.points, point => {
15
+ if (typeof point?.path !== 'string' || !/^\/[^/]/.test(point.path) ||
16
+ point.path.length > 1025 || paths.has(point.path) ||
17
+ !['integer', 'float'].includes(point.type) || !number(point.min) ||
18
+ !number(point.max) || point.min >= point.max ||
19
+ (point.type === 'integer' && (!Number.isSafeInteger(point.min) || !Number.isSafeInteger(point.max))) ||
20
+ typeof point.unit !== 'string' || point.unit.length > 32) throw Error('Invalid meter point')
21
+ paths.add(point.path)
22
+ return Object.freeze({ path: point.path, type: point.type, min: point.min, max: point.max, unit: point.unit })
23
+ })
24
+ const schema = Object.freeze({ ...manifest, points: Object.freeze(points) })
25
+ let latest = null, pending = false, closed = false
26
+ return {
27
+ manifest: schema,
28
+ accept (frame) {
29
+ if (closed || frame?.kind !== 'meter-frame' || frame.streamId !== schema.streamId ||
30
+ frame.epoch !== schema.epoch || frame.layoutVersion !== schema.layoutVersion ||
31
+ !Number.isSafeInteger(frame.seq) || frame.seq < 1 || (latest && frame.seq <= latest.seq) ||
32
+ !number(frame.sampleTime) || frame.sampleTime < 0 ||
33
+ !Array.isArray(frame.values) || frame.values.length !== points.length ||
34
+ !Array.isArray(frame.valid) || frame.valid.length !== points.length) return false
35
+ const values = [], valid = []
36
+ for (let index = 0; index < points.length; index++) {
37
+ const point = points[index], value = frame.values[index], flag = frame.valid[index]
38
+ if (typeof flag !== 'boolean' || (flag && (!number(value) ||
39
+ value < point.min || value > point.max ||
40
+ (point.type === 'integer' && !Number.isSafeInteger(value))))) return false
41
+ values.push(flag ? value : null)
42
+ valid.push(flag)
43
+ }
44
+ latest = Object.freeze({ kind: 'meter-frame', streamId: schema.streamId,
45
+ epoch: schema.epoch, seq: frame.seq, layoutVersion: schema.layoutVersion,
46
+ sampleTime: frame.sampleTime, values: Object.freeze(values), valid: Object.freeze(valid) })
47
+ pending = true
48
+ return true
49
+ },
50
+ snapshot () { return latest },
51
+ take () {
52
+ if (!pending) return null
53
+ pending = false
54
+ return latest
55
+ },
56
+ close () { closed = true; pending = false; latest = null }
57
+ }
58
+ }
59
+
60
+ function createMeterRenderer ({ receiver, requestFrame, cancelFrame, apply }) {
61
+ let handle = null, closed = false
62
+ return {
63
+ receive (frame) {
64
+ if (closed || !receiver.accept(frame)) return false
65
+ if (handle === null) {
66
+ handle = requestFrame(() => {
67
+ handle = null
68
+ const current = receiver.take()
69
+ if (!closed && current) apply(current)
70
+ })
71
+ }
72
+ return true
73
+ },
74
+ close () {
75
+ closed = true
76
+ if (handle !== null) cancelFrame(handle)
77
+ handle = null
78
+ receiver.close()
79
+ }
80
+ }
81
+ }
82
+
83
+ function createRoomMeterReceiver (layout) {
84
+ if (layout?.kind !== 'room-meter-layout' || typeof layout.panelId !== 'string' ||
85
+ typeof layout.room !== 'string' || typeof layout.epoch !== 'string' || !layout.epoch ||
86
+ !Array.isArray(layout.ids) || !layout.ids.length || layout.ids.length > 256 ||
87
+ layout.ids.some(id => typeof id !== 'string' || !id) || new Set(layout.ids).size !== layout.ids.length) {
88
+ throw Error('Invalid Room meter layout')
89
+ }
90
+ let latest = null, pending = false, closed = false
91
+ const count = layout.ids.length
92
+ return {
93
+ accept (frame) {
94
+ if (closed || frame?.kind !== 'room-meter-frame' || frame.panelId !== layout.panelId ||
95
+ frame.room !== layout.room || frame.epoch !== layout.epoch ||
96
+ !Number.isSafeInteger(frame.seq) || frame.seq < 1 || (latest && frame.seq <= latest.seq) ||
97
+ !Array.isArray(frame.values) || frame.values.length !== count ||
98
+ !Array.isArray(frame.valid) || frame.valid.length !== count ||
99
+ frame.valid.some((flag, index) => typeof flag !== 'boolean' || (flag
100
+ ? typeof frame.values[index] !== 'number' || !Number.isFinite(frame.values[index])
101
+ : frame.values[index] !== null))) return false
102
+ latest = { values: frame.values.slice(), valid: frame.valid.slice(), seq: frame.seq }
103
+ pending = true
104
+ return true
105
+ },
106
+ take () { if (!pending) return null; pending = false; return latest },
107
+ close () { closed = true; latest = null; pending = false }
108
+ }
109
+ }
110
+
111
+ module.exports = { createMeterReceiver, createMeterRenderer, createRoomMeterReceiver }
package/meter-route.js ADDED
@@ -0,0 +1,12 @@
1
+ function meterRoute (value) {
2
+ if (value == null) return null
3
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
4
+ typeof value.streamId !== 'string' || !value.streamId.trim() || value.streamId.length > 255 ||
5
+ ![value.eventPath, value.manifestPath].every(path => typeof path === 'string' &&
6
+ /^\/\/[^/]/.test(path) && path.length <= 1026) || value.eventPath === value.manifestPath) {
7
+ throw Error('Invalid meter delivery metadata')
8
+ }
9
+ return { streamId: value.streamId, eventPath: value.eventPath, manifestPath: value.manifestPath }
10
+ }
11
+
12
+ module.exports = { meterRoute }
@@ -1,4 +1,5 @@
1
1
  const { configuredRoutes } = require('./control-bound-protocol')
2
+ const { meterRoute } = require('./meter-route')
2
3
 
3
4
  const supportedTypes = {
4
5
  boolean: { eventType: 'Boolean', eventOptions: { boolParamFilter: 'Any' } },
@@ -28,6 +29,7 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
28
29
  const nodes = []
29
30
  const readTargets = new Map()
30
31
  const writeTargets = new Map()
32
+ const meterStreams = new Map()
31
33
  const commandRules = []
32
34
  const commandWires = []
33
35
 
@@ -51,7 +53,10 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
51
53
  if (['source', 'preset'].includes(type) && !['enum', 'integer', 'string'].includes(target.type)) {
52
54
  throw new Error(`Choice control bound to ${target.type}: ${path}`)
53
55
  }
54
- if (!['mute', 'power', 'level', 'dimmer', 'position', 'text', 'source', 'preset'].includes(type)) {
56
+ if (type === 'meter' && (!target.meter || !['integer', 'float'].includes(target.type) || target.access !== 'ro')) {
57
+ throw new Error(`Meter control must bind to a readonly Meter target: ${path}`)
58
+ }
59
+ if (!['mute', 'power', 'level', 'dimmer', 'position', 'meter', 'text', 'source', 'preset'].includes(type)) {
55
60
  throw new Error(`Control type requires a separate command adapter: ${type}`)
56
61
  }
57
62
  if (!['ro', 'wo', 'rw'].includes(target.access) || !target.access.includes(access)) {
@@ -88,6 +93,47 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
88
93
  if (control.mode !== 'write') {
89
94
  if (!binding.readPath) throw new Error(`Missing read path: ${id}`)
90
95
  const target = metadata(binding.device, binding.readPath, control.type, 'r')
96
+ if (target.meter) {
97
+ const meter = meterRoute(target.meter)
98
+ if (control.mode !== 'read' || control.adapter !== 'meter') throw Error(`Meter is read-only: ${id}`)
99
+ const key = JSON.stringify([target.device, meter.streamId])
100
+ const previous = meterStreams.get(key)
101
+ if (previous) {
102
+ if (JSON.stringify(previous.meter) !== JSON.stringify(meter) || previous.onebased !== target.onebased) {
103
+ throw Error(`Conflicting meter stream: ${meter.streamId}`)
104
+ }
105
+ continue
106
+ }
107
+ const index = meterStreams.size + 1
108
+ const statusId = `${scopeId}-meter-read-${index}`
109
+ const tagIds = []
110
+ meterStreams.set(key, { meter, onebased: target.onebased, statusId, tagIds })
111
+ for (const [role, path] of [['manifest', meter.manifestPath], ['frame', meter.eventPath]]) {
112
+ const eventId = `${scopeId}-meter-${role}-${index}`
113
+ const tagId = `${eventId}-tag`
114
+ tagIds.push(tagId)
115
+ const y = 540 + (index - 1) * 280 + (role === 'frame' ? 140 : 0)
116
+ if (role === 'manifest') nodes.push({
117
+ id: statusId, type: 'Status', z: subflowId, name: `Read meter manifest ${target.device}`,
118
+ x: 440, y: y - 60, mojo: { version: '1.0', device: [target.device],
119
+ parameter: path, onebased: target.onebased }, wires: [[tagId]]
120
+ })
121
+ nodes.push({ id: eventId, type: 'Event', z: subflowId,
122
+ name: `Watch meter ${role} ${target.device} ${meter.streamId}`, x: 440, y,
123
+ mojo: { version: '1.0', device: [target.device], event: path,
124
+ eventKind: role === 'frame' ? 'Event' : 'Parameter', paramType: 'String',
125
+ onebased: target.onebased }, wires: [[tagId]] })
126
+ nodes.push({ id: tagId, type: 'change', z: subflowId,
127
+ name: `Identify meter ${role} ${target.device}`, x: 1000, y,
128
+ rules: [
129
+ { t: 'set', p: 'kind', pt: 'msg', to: 'device-meter-' + role, tot: 'str' },
130
+ { t: 'set', p: 'device', pt: 'msg', to: target.device, tot: 'str' },
131
+ { t: 'set', p: 'path', pt: 'msg', to: path, tot: 'str' },
132
+ { t: 'set', p: 'streamId', pt: 'msg', to: meter.streamId, tot: 'str' }
133
+ ], wires: [[]] })
134
+ }
135
+ continue
136
+ }
91
137
  const key = JSON.stringify([target.device, target.path])
92
138
  if (readTargets.has(key)) continue
93
139
  const index = readTargets.size + 1
@@ -128,6 +174,8 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
128
174
  }
129
175
 
130
176
  const commandSwitchId = `${scopeId}-commands`
177
+ // Meter lanes follow ordinary feedback lanes without overlapping their labels.
178
+ for (const node of nodes) if (node.id.startsWith(`${scopeId}-meter-`)) node.y += readTargets.size * 170
131
179
  const routerId = `${scopeId}-router`
132
180
  if (commandRules.length) {
133
181
  nodes.push({
@@ -148,16 +196,17 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
148
196
  ],
149
197
  checkall: 'false', repair: false, outputs: 2,
150
198
  wires: [commandRules.length ? [commandSwitchId] : [],
151
- [...readTargets.values()].map(target => target.statusId)]
199
+ [...readTargets.values(), ...meterStreams.values()].map(target => target.statusId)]
152
200
  })
153
201
  const subflow = {
154
202
  id: subflowId, type: 'subflow', name: `Binding bridge ${scopeId}`,
155
203
  info: 'Generated panel binding bridge. Requires a Controller node and authenticated command source.',
156
204
  category: 'panel binding',
157
205
  in: [{ x: 170, y: 150, wires: [{ id: routerId }] }],
158
- out: readTargets.size
159
- ? [{ x: 1290, y: Math.round((390 + (readTargets.size + 1) * 85) / 10) * 10,
160
- wires: [...readTargets.values()].map(target => ({ id: target.tagId, port: 0 })) }]
206
+ out: readTargets.size || meterStreams.size
207
+ ? [{ x: meterStreams.size ? 1600 : 1290, y: Math.round((390 + (readTargets.size + meterStreams.size + 1) * 85) / 10) * 10,
208
+ wires: [...readTargets.values()].map(target => ({ id: target.tagId, port: 0 }))
209
+ .concat([...meterStreams.values()].flatMap(target => target.tagIds.map(id => ({ id, port: 0 })))) }]
161
210
  : [],
162
211
  env: [], meta: {}, color: '#96bca7'
163
212
  }
@@ -166,7 +215,8 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
166
215
  manifest: {
167
216
  subflowId,
168
217
  controls: routes.size,
169
- nativeReads: readTargets.size,
218
+ nativeReads: readTargets.size + meterStreams.size,
219
+ nativeMeterStreams: meterStreams.size,
170
220
  nativeWrites: writeTargets.size,
171
221
  requiresController: true,
172
222
  initialReadMessage: { kind: 'read-all' }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "node-red-contrib-room-tools",
3
- "version": "1.8.9",
3
+ "version": "1.8.16",
4
4
  "lockfileVersion": 2,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "node-red-contrib-room-tools",
9
- "version": "1.8.9",
9
+ "version": "1.8.16",
10
10
  "license": "UNLICENSED",
11
11
  "dependencies": {
12
12
  "@vue/compiler-sfc": "3.5.42",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-room-tools",
3
- "version": "1.8.9",
3
+ "version": "1.8.16",
4
4
  "description": "Authenticated visual control panel binding for Node-RED and MUSE Automator",
5
5
  "license": "UNLICENSED",
6
6
  "engines": { "node": ">=18.16.0" },
@@ -21,11 +21,16 @@
21
21
  "control-catalog.js",
22
22
  "control-type-contract.json",
23
23
  "control-type-model.js",
24
+ "meter-route.js",
25
+ "meter-frame.js",
26
+ "panel-meter.js",
27
+ "panel-meter-client.js",
24
28
  "panel-template-instrumentation.js",
25
29
  "panel-visual-bindings.js",
26
30
  "panel-visual-apply.js",
27
31
  "panel-editor-auth.js",
28
32
  "panel-http.js",
33
+ "panel-flow-editor-sync.js",
29
34
  "panel-binding-editor.js",
30
35
  "native-binding-plan.js",
31
36
  "managed-native-bridge.js",
@@ -6,10 +6,15 @@ function initializeVisualBinding () {
6
6
  const root = this.$el
7
7
  const base = config.editorEndpoint
8
8
  const saved = new Map(Object.entries(config.bindings || {}))
9
+ const meterEntries = [...saved].filter(([, entry]) => entry.readMeta?.meter)
10
+ const meterTargets = new Map()
11
+ let meterClient = null, lastMeterFrame = null
9
12
  const attached = new WeakSet()
10
13
  const feedbackModels = new Map()
11
14
  const pendingText = new Map()
12
15
  const cleanup = []
16
+ let disposed = false
17
+ cleanup.push(() => { disposed = true })
13
18
  let editing = new URL(window.location.href).searchParams.get('cp-edit') === config.panelId
14
19
  let editSession = null
15
20
  let snapshot = null
@@ -47,15 +52,15 @@ function initializeVisualBinding () {
47
52
  status.dataset.error = error ? 'true' : 'false'
48
53
  }
49
54
  }
50
- async function request (path, method = 'GET', body) {
51
- const response = await component.__panelFetch(base + path, {
55
+ async function request (path, method = 'GET', body, timeoutMs) {
56
+ const { response, result } = await component.__panelFetch(base + path, {
52
57
  method, credentials: 'same-origin',
53
58
  headers: method === 'POST'
54
59
  ? { 'Content-Type': 'application/json', 'X-Panel-CSRF': editSession?.csrf || '' }
55
60
  : undefined,
56
61
  body: method === 'POST' ? JSON.stringify(body || {}) : undefined
57
- }, path === '/apply' ? 120000 : 12000)
58
- const result = await response.json().catch(() => ({}))
62
+ }, path === '/apply' ? 120000 : (timeoutMs ?? 12000),
63
+ async response => ({ response, result: await response.json() }))
59
64
  if (!response.ok) throw new Error(result.error || `Request failed (${response.status})`)
60
65
  return result
61
66
  }
@@ -104,7 +109,7 @@ function initializeVisualBinding () {
104
109
  }
105
110
  function showFeedback () {
106
111
  for (const [id, binding] of saved) {
107
- if (binding.mode === 'write') continue
112
+ if (binding.mode === 'write' || binding.readMeta?.meter) continue
108
113
  const matches = root.querySelectorAll('[data-cp-auto="' + binding.locator + '"]')
109
114
  if (matches.length !== 1) continue
110
115
  if (!Object.prototype.hasOwnProperty.call(component.boundState, id)) continue
@@ -192,8 +197,44 @@ function initializeVisualBinding () {
192
197
  })
193
198
  } else node.addEventListener('change', () => send(true))
194
199
  }
200
+ if (meterEntries.length) {
201
+ const locators = new Map()
202
+ for (const node of root.querySelectorAll('[data-cp-auto]')) {
203
+ const locator = node.getAttribute('data-cp-auto')
204
+ locators.set(locator, locators.has(locator) ? null : node)
205
+ }
206
+ meterTargets.clear()
207
+ for (const [id, binding] of meterEntries) {
208
+ const node = locators.get(binding.locator)
209
+ if (node) meterTargets.set(id, { node, binding })
210
+ }
211
+ if (lastMeterFrame) applyMeterFrame(lastMeterFrame.frame, lastMeterFrame.ids)
212
+ else invalidateMeters()
213
+ }
195
214
  showFeedback()
196
215
  }
216
+ function applyMeterFrame (frame, ids) {
217
+ lastMeterFrame = { frame, ids }
218
+ for (let index = 0; index < ids.length; index++) {
219
+ const target = meterTargets.get(ids[index])
220
+ if (!target) continue
221
+ const { node, binding } = target
222
+ node.setAttribute('data-cpv-meter-valid', String(frame.valid[index]))
223
+ node.setAttribute('aria-invalid', String(!frame.valid[index]))
224
+ if (!frame.valid[index]) continue
225
+ const value = mappedValue(frame.values[index], binding.readMeta, numericRange(binding, node))
226
+ if (binding.model) {
227
+ if (binding.model in component && !component.$options?.computed?.[binding.model]) component[binding.model] = value
228
+ } else node.value = value
229
+ }
230
+ }
231
+ function invalidateMeters () {
232
+ lastMeterFrame = null
233
+ for (const { node } of meterTargets.values()) {
234
+ node.setAttribute('data-cpv-meter-valid', 'false')
235
+ node.setAttribute('aria-invalid', 'true')
236
+ }
237
+ }
197
238
  function attachModelControls () {
198
239
  for (const [id, binding] of saved) {
199
240
  if (!binding.component || !binding.model || binding.mode === 'read') continue
@@ -330,6 +371,7 @@ function initializeVisualBinding () {
330
371
  }
331
372
  function closeLoginScreen () { loginScreen?.remove(); loginScreen = null }
332
373
  function showLoginForm (title, login, editor = false) {
374
+ if (disposed) return
333
375
  const content = coverPage(title, editor ? 'editor' : 'panel', 'login')
334
376
  const form = element('form')
335
377
  const user = field(form, editor ? 'Editor username' : 'Username', element('input'))
@@ -353,6 +395,7 @@ function initializeVisualBinding () {
353
395
  message.textContent = ''
354
396
  try {
355
397
  await login(user.value, password.value)
398
+ if (disposed) return
356
399
  closeLoginScreen()
357
400
  } catch (error) {
358
401
  message.textContent = error.message
@@ -366,13 +409,13 @@ function initializeVisualBinding () {
366
409
  }
367
410
  function loginView () {
368
411
  showLoginForm('ControlPanel editor', async (username, password) => {
369
- const response = await component.__panelFetch(base + '/login', {
412
+ const { response, result } = await component.__panelFetch(base + '/login', {
370
413
  method: 'POST', credentials: 'same-origin',
371
414
  headers: { 'Content-Type': 'application/json' },
372
415
  body: JSON.stringify({ username, password })
373
- })
374
- const result = await response.json()
416
+ }, 12000, async response => ({ response, result: await response.json() }))
375
417
  if (!response.ok) throw new Error(result.error || 'Login failed')
418
+ if (disposed) return
376
419
  editSession = result
377
420
  await loadEditor()
378
421
  }, true)
@@ -382,7 +425,9 @@ function initializeVisualBinding () {
382
425
  }
383
426
  async function loadEditor () {
384
427
  snapshot = await request('/config')
428
+ if (disposed) return
385
429
  const response = await request('/devices')
430
+ if (disposed) return
386
431
  devices = response.devices
387
432
  draft = snapshot.bindings.map(entry => ({ ...entry, onebased: numberingMode(entry) }))
388
433
  closeOverlay()
@@ -390,6 +435,7 @@ function initializeVisualBinding () {
390
435
  renderToolbar()
391
436
  inform('Choose a control on the page')
392
437
  updateBindingMarkers()
438
+ meterClient?.setAuth({ endpoint: base, csrf: editSession.csrf, header: 'X-Panel-CSRF' })
393
439
  }
394
440
  function choose (label, options, selected) {
395
441
  const select = element('select')
@@ -658,6 +704,25 @@ function initializeVisualBinding () {
658
704
  }
659
705
  attachControls()
660
706
  attachModelControls()
707
+ if (this.__boundPanelConfig().meter && meterEntries.length) {
708
+ try {
709
+ const ids = meterEntries.map(([id]) => id).sort((a, b) => a.localeCompare(b))
710
+ meterClient = this.__createPanelMeterClient({ socket: this.$socket,
711
+ panelId: config.panelId, room: this.__boundPanelConfig().room, ids,
712
+ request: (...args) => component.__panelFetch(...args),
713
+ createReceiver: this.__createRoomMeterReceiver, createRenderer: this.__createMeterRenderer,
714
+ apply: applyMeterFrame, invalidate: invalidateMeters,
715
+ onUnauthorized: () => {
716
+ if (editing) { editSession = null; loginView() }
717
+ else component.clearBoundPanelSession()
718
+ } })
719
+ cleanup.push(() => meterClient.close())
720
+ if (!editing) cleanup.push(this.$watch('boundAuthenticated', authenticated => {
721
+ meterClient.setAuth(authenticated ? { endpoint: component.__boundPanelConfig().endpoint,
722
+ csrf: component.__boundPanelSession.csrf, header: 'X-Room-CSRF' } : null)
723
+ }))
724
+ } catch (error) { console.error('ControlPanel meter initialization failed', error) }
725
+ }
661
726
  observer = new MutationObserver(() => {
662
727
  attachControls()
663
728
  updateBindingMarkers()
@@ -730,6 +795,7 @@ function initializeVisualBinding () {
730
795
  .cpv-binding-actions{display:flex;gap:6px;flex-shrink:0}
731
796
  body.cpv-editing [data-cpv-bound="true"]{outline:2px solid #b526f2 !important;outline-offset:3px;box-shadow:0 0 0 4px #b526f255 !important}
732
797
  body.cpv-editing [data-cp-auto]:hover{outline:2px solid #b526f2;outline-offset:2px}
798
+ [data-cpv-meter-valid="false"]{opacity:.45}
733
799
  `
734
800
  document.head.append(style)
735
801
  cleanup.push(() => style.remove())
@@ -737,7 +803,11 @@ function initializeVisualBinding () {
737
803
  document.body.classList.add('cpv-editing')
738
804
  cleanup.push(() => document.body.classList.remove('cpv-editing'))
739
805
  coverPage('Loading...', 'editor')
740
- request('/session').then(session => { editSession = session; return loadEditor() })
806
+ request('/session', 'GET', undefined, 2500).then(session => {
807
+ if (disposed) return
808
+ editSession = session
809
+ return loadEditor()
810
+ })
741
811
  .then(closeLoginScreen).catch(() => loginView())
742
812
  } else {
743
813
  coverPage('Loading...', 'panel')
@@ -745,10 +815,13 @@ function initializeVisualBinding () {
745
815
  if (authenticated) closeLoginScreen()
746
816
  else if (!loginScreen) normalLoginView()
747
817
  }))
748
- component.restoreBoundPanelSession().then(session => {
818
+ component.restoreBoundPanelSession(2500).then(session => {
819
+ if (disposed) return
749
820
  if (session || component.boundAuthenticated) closeLoginScreen()
750
821
  else normalLoginView()
751
- }).catch(() => normalLoginView())
822
+ }).catch(() => {
823
+ normalLoginView()
824
+ })
752
825
  }
753
826
  }
754
827
 
@@ -1,7 +1,7 @@
1
1
  const crypto = require('node:crypto')
2
2
  const { readJsonBody, replyJson: reply, sameOrigin } = require('./panel-http')
3
3
 
4
- function createPanelEditor ({ panelId, username, password, secureCookie, getConfig, getDevices, getTargets, apply, restoredSessions }) {
4
+ function createPanelEditor ({ panelId, username, password, secureCookie, getConfig, getDevices, getTargets, apply, restoredSessions, onAuthenticatedRequest }) {
5
5
  if (!username || !password) return null
6
6
  const prefix = `/control-panel-edit/panels/${panelId}`
7
7
  const cookieName = `cp_edit_${panelId.replace(/[^a-zA-Z0-9]/g, '_')}`
@@ -71,6 +71,8 @@ function createPanelEditor ({ panelId, username, password, secureCookie, getConf
71
71
  reply(res, 403, { error: 'CSRF rejected' })
72
72
  return true
73
73
  }
74
+ if (onAuthenticatedRequest && await onAuthenticatedRequest(req, res,
75
+ { token, expiry: session.expiry })) return true
74
76
  if (path === prefix + '/session' && req.method === 'GET') {
75
77
  reply(res, 200, { csrf: session.csrf })
76
78
  } else if (path === prefix + '/logout' && req.method === 'POST') {
@@ -84,7 +86,7 @@ function createPanelEditor ({ panelId, username, password, secureCookie, getConf
84
86
  const device = new URL(req.url, 'http://localhost').searchParams.get('device')
85
87
  reply(res, 200, { targets: await getTargets(req, device) })
86
88
  } else if (path === prefix + '/apply' && req.method === 'POST') {
87
- reply(res, 200, await apply(req, await readJsonBody(req, 65536)))
89
+ reply(res, 200, await apply(req, await readJsonBody(req, 524288)))
88
90
  } else {
89
91
  reply(res, 404, { error: 'Not found' })
90
92
  }
@@ -97,6 +99,7 @@ function createPanelEditor ({ panelId, username, password, secureCookie, getConf
97
99
 
98
100
  return {
99
101
  handler,
102
+ sessionActive: token => !closed && (sessions.get(token)?.expiry || 0) > Date.now(),
100
103
  sessionSnapshot () {
101
104
  return new Map([...sessions].filter(([, session]) => session.expiry > Date.now())
102
105
  .map(([token, session]) => [token, { ...session }]))
@@ -0,0 +1,53 @@
1
+ (function (root, factory) {
2
+ if (typeof window === 'object') root.ControlPanelFlowEditorSync = factory()
3
+ else if (typeof module === 'object' && module.exports) module.exports = factory()
4
+ })(typeof window === 'object' ? window : this, function () {
5
+ const topic = 'notification/runtime-deploy'
6
+
7
+ // Node-RED already owns the Flow-changed notification. This listener only
8
+ // reloads the editor page after a newer runtime revision is announced. It
9
+ // must never read, clear, import, or deploy Flow data.
10
+ function createController (hooks) {
11
+ let stopped = false
12
+ let reloading = false
13
+
14
+ function request (message) {
15
+ if (stopped || reloading || !message || typeof message.revision !== 'string') return
16
+ const current = hooks.version()
17
+ // A retained notification received during page startup describes the
18
+ // Flow that is already loaded. Ignore it instead of causing a reload.
19
+ if (typeof current !== 'string' || current === message.revision) return
20
+ reloading = true
21
+ hooks.reload()
22
+ }
23
+
24
+ return { request, stop: () => { stopped = true } }
25
+ }
26
+
27
+ function install (RED, $, host = window) {
28
+ if (RED.__controlPanelFlowSync) return RED.__controlPanelFlowSync
29
+ if (!RED.comms?.subscribe || !RED.nodes?.version || !host.location?.reload) {
30
+ RED.notify('ControlPanel Flow change listener is unavailable in this editor.', 'warning')
31
+ return
32
+ }
33
+
34
+ const controller = createController({
35
+ version: () => RED.nodes.version(),
36
+ reload: () => host.location.reload()
37
+ })
38
+ const changed = (_topic, message) => controller.request(message)
39
+ RED.comms.subscribe(topic, changed)
40
+ const instance = {
41
+ request: controller.request,
42
+ dispose () {
43
+ controller.stop()
44
+ RED.comms.unsubscribe(topic, changed)
45
+ delete RED.__controlPanelFlowSync
46
+ }
47
+ }
48
+ RED.__controlPanelFlowSync = instance
49
+ return instance
50
+ }
51
+
52
+ return { topic, createController, install }
53
+ })
package/panel-http.js CHANGED
@@ -56,14 +56,37 @@ async function readJsonBody (req, maxBytes) {
56
56
  }
57
57
 
58
58
  // Serialized into the Vue component, so keep this function browser-only and self-contained.
59
- async function fetchWithTimeout (url, options = {}, timeoutMs = 12000) {
59
+ async function fetchWithTimeout (url, options = {}, timeoutMs = 12000, readResponse) {
60
60
  const controller = new AbortController()
61
- const timer = setTimeout(() => controller.abort(), timeoutMs)
61
+ let timedOut = false
62
+ const timer = setTimeout(() => { timedOut = true; controller.abort() }, timeoutMs)
62
63
  try {
63
- return await fetch(url, { ...options, signal: controller.signal })
64
+ const response = await fetch(url, { ...options, signal: controller.signal })
65
+ return readResponse ? await readResponse(response) : response
66
+ } catch (error) {
67
+ if (timedOut) throw new Error('Request timed out; your binding draft is still available')
68
+ throw error
64
69
  } finally {
65
70
  clearTimeout(timer)
66
71
  }
67
72
  }
68
73
 
69
- module.exports = { sameOrigin, replyJson, readJsonBody, fetchWithTimeout }
74
+ // Keep the deadline active through JSON decoding, not just response headers.
75
+ async function runtimeJsonRequest (url, options, { fetcher = fetch, timeoutMs, operation }) {
76
+ const controller = new AbortController()
77
+ let timedOut = false
78
+ const timer = setTimeout(() => { timedOut = true; controller.abort() }, timeoutMs)
79
+ try {
80
+ const response = await fetcher(url, { ...options, signal: controller.signal })
81
+ if (!response.ok) return { response, data: null }
82
+ return { response, data: await response.json() }
83
+ } catch (cause) {
84
+ const error = new Error(`${operation} ${timedOut ? 'timed out' : 'failed'}; ${timedOut ? 'the runtime did not respond in time' : cause.message}`, { cause })
85
+ error.status = 503
86
+ throw error
87
+ } finally {
88
+ clearTimeout(timer)
89
+ }
90
+ }
91
+
92
+ module.exports = { sameOrigin, replyJson, readJsonBody, fetchWithTimeout, runtimeJsonRequest }