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

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.
@@ -7,8 +7,11 @@ function initializeVisualBinding () {
7
7
  const base = config.editorEndpoint
8
8
  const saved = new Map(Object.entries(config.bindings || {}))
9
9
  const meterEntries = [...saved].filter(([, entry]) => entry.readMeta?.meter)
10
+ const xySeriesEntries = [...saved].filter(([, entry]) => entry.readMeta?.xySeries)
10
11
  const meterTargets = new Map()
12
+ const xySeriesTargets = new Map()
11
13
  let meterClient = null, lastMeterFrame = null
14
+ let xySeriesClient = null, lastXYSeriesFrame = null
12
15
  const attached = new WeakSet()
13
16
  const feedbackModels = new Map()
14
17
  const pendingText = new Map()
@@ -107,25 +110,106 @@ function initializeVisualBinding () {
107
110
  }
108
111
  return String(node.value)
109
112
  }
113
+ function repeatMarkerFor (node, repeat) {
114
+ let current = node
115
+ while (current && current !== root) {
116
+ if (current.getAttribute?.('data-cp-repeat') === repeat?.locator) {
117
+ const key = current.getAttribute('data-cp-repeat-key')
118
+ return key == null ? null : String(key)
119
+ }
120
+ current = current.parentElement
121
+ }
122
+ return null
123
+ }
124
+ function bindingMatchesNode (node, binding) {
125
+ if (node.getAttribute('data-cp-auto') !== binding.locator) return false
126
+ if (!binding.repeat) return true
127
+ return repeatMarkerFor(node, binding.repeat) === String(binding.instanceKey)
128
+ }
129
+ function nodesForBinding (binding) {
130
+ return [...root.querySelectorAll('[data-cp-auto]')].filter(node => bindingMatchesNode(node, binding))
131
+ }
132
+ function nodeForBinding (binding) {
133
+ const matches = nodesForBinding(binding)
134
+ return matches.length === 1 ? matches[0] : null
135
+ }
136
+ function repeatItem (binding) {
137
+ const repeat = binding.repeat
138
+ const source = component[repeat.source]
139
+ if (!Array.isArray(source)) return undefined
140
+ if (repeat.kind === 'array') return source[Number(binding.instanceKey)]
141
+ const keyField = repeat.key.slice(repeat.value.length + 1)
142
+ return source.find(value => value && String(value[keyField]) === String(binding.instanceKey))
143
+ }
144
+ function readRepeatField (item, field) {
145
+ return field.split('.').reduce((value, name) => value == null ? undefined : value[name], item)
146
+ }
147
+ function writeRepeatField (item, field, value) {
148
+ const names = field.split('.')
149
+ const last = names.pop()
150
+ let target = item
151
+ for (const name of names) {
152
+ if (!target || typeof target !== 'object') return false
153
+ target = target[name]
154
+ }
155
+ if (!target || typeof target !== 'object') return false
156
+ if (!Object.is(target[last], value)) target[last] = value
157
+ return true
158
+ }
159
+ function repeatModelValue (binding) {
160
+ if (!binding.repeat || !binding.model) return binding.model ? component[binding.model] : undefined
161
+ const repeat = binding.repeat
162
+ const source = component[repeat.source]
163
+ if (!Array.isArray(source)) return undefined
164
+ const item = repeatItem(binding)
165
+ if (repeat.kind === 'array' && binding.model === `${repeat.source}[${repeat.index}]`) return item
166
+ const prefix = repeat.value + '.'
167
+ const indexedPrefix = repeat.index ? `${repeat.source}[${repeat.index}].` : ''
168
+ const field = binding.model.startsWith(prefix) ? binding.model.slice(prefix.length)
169
+ : indexedPrefix && binding.model.startsWith(indexedPrefix)
170
+ ? binding.model.slice(indexedPrefix.length) : ''
171
+ return item && field ? readRepeatField(item, field) : undefined
172
+ }
173
+ function setRepeatModel (binding, value) {
174
+ if (!binding.repeat || !binding.model) return false
175
+ const repeat = binding.repeat
176
+ const source = component[repeat.source]
177
+ if (!Array.isArray(source)) return false
178
+ const item = repeatItem(binding)
179
+ const prefix = repeat.value + '.'
180
+ const indexedPrefix = repeat.index ? `${repeat.source}[${repeat.index}].` : ''
181
+ const field = binding.model.startsWith(prefix) ? binding.model.slice(prefix.length)
182
+ : indexedPrefix && binding.model.startsWith(indexedPrefix)
183
+ ? binding.model.slice(indexedPrefix.length) : ''
184
+ if (field) {
185
+ return writeRepeatField(item, field, value)
186
+ }
187
+ if (repeat.kind !== 'array' || binding.model !== `${repeat.source}[${repeat.index}]`) return false
188
+ const index = Number(binding.instanceKey)
189
+ if (!Number.isInteger(index) || index < 0 || index >= source.length) return false
190
+ if (!Object.is(source[index], value)) source[index] = value
191
+ return true
192
+ }
110
193
  function showFeedback () {
111
194
  for (const [id, binding] of saved) {
112
- if (binding.mode === 'write' || binding.readMeta?.meter) continue
113
- const matches = root.querySelectorAll('[data-cp-auto="' + binding.locator + '"]')
114
- if (matches.length !== 1) continue
195
+ if (binding.mode === 'write' || binding.readMeta?.meter || binding.readMeta?.xySeries) continue
196
+ const node = nodeForBinding(binding)
197
+ if (!node) continue
115
198
  if (!Object.prototype.hasOwnProperty.call(component.boundState, id)) continue
116
199
  const value = component.boundDisplayValue(id, component.boundState[id])
117
- const node = matches[0]
118
200
  const displayValue = ['range', 'meter'].includes(binding.adapter)
119
201
  ? mappedValue(Number(value), binding.readMeta || binding.writeMeta,
120
202
  numericRange(binding, node)) : value
121
203
  if (binding.model) {
122
204
  if (binding.component && binding.adapter === 'text-input' &&
123
205
  (pendingText.has(id) || node.contains?.(document.activeElement))) continue
124
- if (binding.model in component && !component.$options?.computed?.[binding.model]) {
125
- if (!Object.is(component[binding.model], displayValue)) {
206
+ const current = repeatModelValue(binding)
207
+ if (binding.repeat || (binding.model in component && !component.$options?.computed?.[binding.model])) {
208
+ if (!Object.is(current, displayValue)) {
126
209
  pendingText.delete(id)
127
210
  if (binding.component) feedbackModels.set(id, displayValue)
128
- component[binding.model] = displayValue
211
+ if (binding.repeat) setRepeatModel(binding, displayValue)
212
+ else component[binding.model] = displayValue
129
213
  if (binding.component && component.$nextTick) {
130
214
  void component.$nextTick(() => {
131
215
  if (Object.is(feedbackModels.get(id), displayValue)) feedbackModels.delete(id)
@@ -151,11 +235,12 @@ function initializeVisualBinding () {
151
235
  function attachControls () {
152
236
  for (const node of root.querySelectorAll('[data-cp-auto]')) {
153
237
  if (attached.has(node)) continue
154
- attached.add(node)
155
238
  const locator = node.getAttribute('data-cp-auto')
156
- const matched = [...saved].find(([, entry]) => entry.locator === locator)
239
+ const matched = [...saved].find(([, entry]) => bindingMatchesNode(node, entry))
157
240
  if (!matched) continue
158
241
  const [id, binding] = matched
242
+ if (nodeForBinding(binding) !== node) continue
243
+ attached.add(node)
159
244
  if (binding.mode === 'read') continue
160
245
  if (binding.component) {
161
246
  if (binding.adapter === 'text-input') {
@@ -198,19 +283,23 @@ function initializeVisualBinding () {
198
283
  } else node.addEventListener('change', () => send(true))
199
284
  }
200
285
  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
286
  meterTargets.clear()
207
287
  for (const [id, binding] of meterEntries) {
208
- const node = locators.get(binding.locator)
288
+ const node = nodeForBinding(binding)
209
289
  if (node) meterTargets.set(id, { node, binding })
210
290
  }
211
291
  if (lastMeterFrame) applyMeterFrame(lastMeterFrame.frame, lastMeterFrame.ids)
212
292
  else invalidateMeters()
213
293
  }
294
+ if (xySeriesEntries.length) {
295
+ xySeriesTargets.clear()
296
+ for (const [id, binding] of xySeriesEntries) {
297
+ const node = nodeForBinding(binding)
298
+ if (node) xySeriesTargets.set(id, { node, binding })
299
+ }
300
+ if (lastXYSeriesFrame) applyXYSeriesFrame(lastXYSeriesFrame.frame, lastXYSeriesFrame.ids)
301
+ else invalidateXYSeries()
302
+ }
214
303
  showFeedback()
215
304
  }
216
305
  function applyMeterFrame (frame, ids) {
@@ -224,7 +313,8 @@ function initializeVisualBinding () {
224
313
  if (!frame.valid[index]) continue
225
314
  const value = mappedValue(frame.values[index], binding.readMeta, numericRange(binding, node))
226
315
  if (binding.model) {
227
- if (binding.model in component && !component.$options?.computed?.[binding.model]) component[binding.model] = value
316
+ if (binding.repeat) setRepeatModel(binding, value)
317
+ else if (binding.model in component && !component.$options?.computed?.[binding.model]) component[binding.model] = value
228
318
  } else node.value = value
229
319
  }
230
320
  }
@@ -235,6 +325,32 @@ function initializeVisualBinding () {
235
325
  node.setAttribute('aria-invalid', 'true')
236
326
  }
237
327
  }
328
+ function applyXYSeriesFrame (frame, ids) {
329
+ lastXYSeriesFrame = { frame, ids }
330
+ for (let index = 0; index < ids.length; index++) {
331
+ const target = xySeriesTargets.get(ids[index])
332
+ if (!target) continue
333
+ const { node, binding } = target
334
+ node.setAttribute('data-cpv-xy-series-valid', String(frame.valid[index]))
335
+ node.setAttribute('data-cpv-xy-series-seq', String(frame.seq))
336
+ const points = frame.valid[index] ? frame.values[index].map(point => point.slice()) : []
337
+ if (binding.model) {
338
+ if (binding.repeat) setRepeatModel(binding, points)
339
+ else if (binding.model in component && !component.$options?.computed?.[binding.model]) component[binding.model] = points
340
+ }
341
+ }
342
+ }
343
+ function invalidateXYSeries () {
344
+ lastXYSeriesFrame = null
345
+ for (const { node, binding } of xySeriesTargets.values()) {
346
+ node.setAttribute('data-cpv-xy-series-valid', 'false')
347
+ node.setAttribute('aria-invalid', 'true')
348
+ if (binding.model) {
349
+ if (binding.repeat) setRepeatModel(binding, [])
350
+ else if (binding.model in component && !component.$options?.computed?.[binding.model]) component[binding.model] = []
351
+ }
352
+ }
353
+ }
238
354
  function attachModelControls () {
239
355
  for (const [id, binding] of saved) {
240
356
  if (!binding.component || !binding.model || binding.mode === 'read') continue
@@ -271,7 +387,9 @@ function initializeVisualBinding () {
271
387
  console.error('ControlPanel binding command failed', error)
272
388
  }
273
389
  }
274
- const unwatch = component.$watch(binding.model, value => {
390
+ const unwatch = component.$watch(
391
+ binding.repeat ? () => repeatModelValue(binding) : binding.model,
392
+ value => {
275
393
  if (feedbackModels.has(id)) {
276
394
  const feedbackValue = feedbackModels.get(id)
277
395
  feedbackModels.delete(id)
@@ -287,21 +405,25 @@ function initializeVisualBinding () {
287
405
  clearTimeout(finishTimer)
288
406
  finishTimer = setTimeout(() => send(value, true), 160)
289
407
  } else send(value, true)
290
- })
408
+ })
291
409
  cleanup.push(() => { clearTimeout(finishTimer); unwatch() })
292
410
  }
293
411
  }
294
412
  function closeOverlay () { overlay?.remove(); overlay = null }
413
+ function sameBindingTarget (entry, candidate) {
414
+ return entry.locator === candidate.locator &&
415
+ (!candidate.repeat || String(entry.instanceKey) === String(candidate.instanceKey))
416
+ }
295
417
  function updateBindingMarkers () {
296
418
  for (const node of root.querySelectorAll('[data-cpv-bound="true"]')) {
297
419
  node.removeAttribute('data-cpv-bound')
298
420
  node.removeAttribute('data-cpv-binding-id')
299
421
  }
300
422
  if (!editing || !editSession) return
301
- const bindings = new Map(draft.map(entry => [entry.locator, entry]))
302
423
  for (const node of root.querySelectorAll('[data-cp-auto]')) {
303
- const entry = bindings.get(node.getAttribute('data-cp-auto'))
424
+ const entry = draft.find(item => bindingMatchesNode(node, item))
304
425
  if (!entry) continue
426
+ if (nodeForBinding(entry) !== node) continue
305
427
  node.setAttribute('data-cpv-bound', 'true')
306
428
  node.setAttribute('data-cpv-binding-id', entry.id)
307
429
  }
@@ -310,10 +432,10 @@ function initializeVisualBinding () {
310
432
  closeOverlay()
311
433
  overlay = element('div', null, 'cpv-menu')
312
434
  overlay.setAttribute('role', 'menu')
313
- const existing = draft.find(entry => entry.locator === candidate.locator)
435
+ const existing = draft.find(entry => sameBindingTarget(entry, candidate))
314
436
  overlay.append(button(existing ? 'Edit binding' : 'Bind control', () => bindingDialog(candidate)))
315
437
  if (existing) overlay.append(button('Remove binding', () => {
316
- draft = draft.filter(entry => entry.locator !== candidate.locator)
438
+ draft = draft.filter(entry => !sameBindingTarget(entry, candidate))
317
439
  updateBindingMarkers()
318
440
  closeOverlay()
319
441
  inform('Binding removed from draft')
@@ -457,14 +579,17 @@ function initializeVisualBinding () {
457
579
  }
458
580
  async function bindingDialog (candidate, options = {}) {
459
581
  const returnToBindings = options.returnToBindings === true
460
- const existing = draft.find(entry => entry.locator === candidate.locator)
582
+ const existing = draft.find(entry => sameBindingTarget(entry, candidate))
461
583
  const dialog = showDialog(existing ? 'Edit binding' : 'Bind control')
462
584
  const form = element('form')
463
585
  const name = field(form, 'Name', element('input'))
464
- name.value = existing?.label || candidate.label
586
+ name.value = existing?.label || (candidate.repeat ? `${candidate.label} [${candidate.instanceKey}]` : candidate.label)
465
587
  name.maxLength = 80
466
588
  const id = field(form, 'Control ID', element('input'))
467
- id.value = existing?.id || 'control.' + candidate.locator.slice(-12)
589
+ const instanceSuffix = candidate.repeat
590
+ ? '.' + String(candidate.instanceKey).replace(/[^A-Za-z0-9._/-]/g, '-').slice(0, 48)
591
+ : ''
592
+ id.value = existing?.id || 'control.' + candidate.locator.slice(-12) + instanceSuffix
468
593
  const modeOptions = ['meter', 'text-display'].includes(candidate.adapter)
469
594
  ? ['read'] : ['both', 'write', 'read']
470
595
  const mode = field(form, 'Mode', choose('Mode', modeOptions.map(value => ({ value, text: value })), existing?.mode || modeOptions[0]))
@@ -532,7 +657,7 @@ function initializeVisualBinding () {
532
657
  const actions = element('div', null, 'cpv-actions')
533
658
  actions.append(button('Cancel', () => finishBindingEdit(returnToBindings)))
534
659
  if (existing) actions.append(button('Remove', () => {
535
- draft = draft.filter(entry => entry.locator !== candidate.locator)
660
+ draft = draft.filter(entry => !sameBindingTarget(entry, candidate))
536
661
  finishBindingEdit(returnToBindings)
537
662
  inform('Binding removed from draft')
538
663
  }))
@@ -543,7 +668,7 @@ function initializeVisualBinding () {
543
668
  form.addEventListener('submit', event => {
544
669
  event.preventDefault()
545
670
  if (!/^[A-Za-z][A-Za-z0-9._/-]{0,127}$/.test(id.value) ||
546
- draft.some(entry => entry.id === id.value && entry.locator !== candidate.locator)) {
671
+ draft.some(entry => entry.id === id.value && !sameBindingTarget(entry, candidate))) {
547
672
  message.textContent = 'Control ID must be unique and valid'; return
548
673
  }
549
674
  if (!name.value.trim() || !device.value || (mode.value !== 'read' && !write.value) ||
@@ -553,12 +678,13 @@ function initializeVisualBinding () {
553
678
  const entry = {
554
679
  id: id.value, locator: candidate.locator, adapter: candidate.adapter,
555
680
  ...(candidate.model ? { model: candidate.model } : {}),
681
+ ...(candidate.repeat ? { repeat: candidate.repeat, instanceKey: String(candidate.instanceKey) } : {}),
556
682
  label: name.value.trim(), mode: mode.value, device: device.value,
557
683
  writePath: mode.value === 'read' ? '' : write.value,
558
684
  readPath: mode.value === 'write' ? '' : read.value,
559
685
  onebased: numbering.value === 'true'
560
686
  }
561
- draft = draft.filter(item => item.locator !== candidate.locator)
687
+ draft = draft.filter(item => !sameBindingTarget(item, candidate))
562
688
  draft.push(entry)
563
689
  updateBindingMarkers()
564
690
  finishBindingEdit(returnToBindings)
@@ -612,6 +738,8 @@ function initializeVisualBinding () {
612
738
  const position = (order.get(a.locator) ?? Number.MAX_SAFE_INTEGER) -
613
739
  (order.get(b.locator) ?? Number.MAX_SAFE_INTEGER)
614
740
  if (position) return position
741
+ const instancePosition = String(a.instanceKey || '').localeCompare(String(b.instanceKey || ''))
742
+ if (instancePosition) return instancePosition
615
743
  return String(a.id).localeCompare(String(b.id))
616
744
  })
617
745
  }
@@ -626,10 +754,11 @@ function initializeVisualBinding () {
626
754
  details.append(element('span', path, 'cpv-binding-path'))
627
755
  row.append(details)
628
756
  const actions = element('div', null, 'cpv-binding-actions')
629
- const candidate = snapshot.candidates.find(item => item.locator === entry.locator && item.supported)
757
+ const baseCandidate = snapshot.candidates.find(item => item.locator === entry.locator && item.supported)
758
+ const candidate = baseCandidate?.repeat ? { ...baseCandidate, instanceKey: entry.instanceKey } : baseCandidate
630
759
  const edit = button('Edit', () => bindingDialog(candidate, { returnToBindings: true }))
631
- edit.disabled = !candidate
632
- if (!candidate) edit.title = 'This control is no longer available on the page'
760
+ edit.disabled = !candidate || !nodeForBinding(entry)
761
+ if (edit.disabled) edit.title = 'This control is no longer available on the page'
633
762
  actions.append(edit)
634
763
  actions.append(button('Remove', () => {
635
764
  draft = draft.filter(item => item.id !== entry.id)
@@ -650,9 +779,17 @@ function initializeVisualBinding () {
650
779
  function candidateFromEvent (event) {
651
780
  const node = event.target?.closest?.('[data-cp-auto]')
652
781
  if (!node || !root.contains(node) || !snapshot) return null
653
- const candidate = snapshot.candidates.find(item => item.locator === node.getAttribute('data-cp-auto'))
654
- if (!candidate?.component || !candidate.model) return candidate
655
- const value = component[candidate.model]
782
+ const baseCandidate = snapshot.candidates.find(item => item.locator === node.getAttribute('data-cp-auto'))
783
+ if (!baseCandidate) return null
784
+ const instanceKey = baseCandidate.repeat ? repeatMarkerFor(node, baseCandidate.repeat) : undefined
785
+ const candidate = baseCandidate.repeat
786
+ ? { ...baseCandidate, ...(instanceKey == null ? {} : { instanceKey }) }
787
+ : baseCandidate
788
+ if (candidate.repeat && instanceKey == null) {
789
+ return { ...candidate, supported: false, reason: 'Repeated control instance key is unavailable' }
790
+ }
791
+ if (!candidate.component || !candidate.model) return candidate
792
+ const value = candidate.repeat ? repeatModelValue(candidate) : component[candidate.model]
656
793
  if (value == null) return candidate
657
794
  if (candidate.adapter === 'switch' && typeof value !== 'boolean') {
658
795
  return { ...candidate, supported: false, reason: 'This Vuetify switch needs a boolean v-model field' }
@@ -663,6 +800,9 @@ function initializeVisualBinding () {
663
800
  if (candidate.adapter === 'meter' && (typeof value !== 'number' || !Number.isFinite(value))) {
664
801
  return { ...candidate, supported: false, reason: 'This Vuetify progress needs a numeric model field' }
665
802
  }
803
+ if (candidate.adapter === 'xy-series' && !Array.isArray(value)) {
804
+ return { ...candidate, supported: false, reason: 'This realtime array control needs an array model field' }
805
+ }
666
806
  if (candidate.adapter === 'select' && typeof value !== 'string' &&
667
807
  !(typeof value === 'number' && Number.isFinite(value))) {
668
808
  return { ...candidate, supported: false, reason: 'This Vuetify selection needs one scalar model field' }
@@ -723,6 +863,25 @@ function initializeVisualBinding () {
723
863
  }))
724
864
  } catch (error) { console.error('ControlPanel meter initialization failed', error) }
725
865
  }
866
+ if (this.__boundPanelConfig().xySeries && xySeriesEntries.length) {
867
+ try {
868
+ const ids = xySeriesEntries.map(([id]) => id).sort((a, b) => a.localeCompare(b))
869
+ xySeriesClient = this.__createPanelXYSeriesClient({ socket: this.$socket,
870
+ panelId: config.panelId, room: this.__boundPanelConfig().room, ids,
871
+ request: (...args) => component.__panelFetch(...args),
872
+ createReceiver: this.__createXYSeriesReceiver, createRenderer: this.__createXYSeriesRenderer,
873
+ apply: applyXYSeriesFrame, invalidate: invalidateXYSeries,
874
+ onUnauthorized: () => {
875
+ if (editing) { editSession = null; loginView() }
876
+ else component.clearBoundPanelSession()
877
+ } })
878
+ cleanup.push(() => xySeriesClient.close())
879
+ if (!editing) cleanup.push(this.$watch('boundAuthenticated', authenticated => {
880
+ xySeriesClient.setAuth(authenticated ? { endpoint: component.__boundPanelConfig().endpoint,
881
+ csrf: component.__boundPanelSession.csrf, header: 'X-Room-CSRF' } : null)
882
+ }))
883
+ } catch (error) { console.error('ControlPanel xy-series initialization failed', error) }
884
+ }
726
885
  observer = new MutationObserver(() => {
727
886
  attachControls()
728
887
  updateBindingMarkers()
package/panel-meter.js CHANGED
@@ -188,7 +188,8 @@ function createPanelMeter ({ panelId, room, bindings, hub, onSubscribe = () => {
188
188
  subscribers.set(socket.id, { socket, active: entry.active, disconnect })
189
189
  socket.on('disconnect', disconnect)
190
190
  socket.emit('control-panel:meter-ready', layout)
191
- emitFrame(socket, hub.snapshot(panelId))
191
+ const initial = hub.snapshot(panelId)
192
+ emitFrame(socket, initial)
192
193
  onSubscribe()
193
194
  },
194
195
  'control-panel:meter-unsubscribe': (socket, id) => { if (id === panelId) remove(socket.id) }