node-red-contrib-room-tools 1.9.0 → 1.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,10 +6,11 @@ const { meterRoute } = require('./meter-route')
6
6
  // Used by both server validation and the injected editor; keep it self-contained.
7
7
  function createTargetMatcher (typeContract) {
8
8
  const families = {
9
- range: ['number'], switch: ['boolean'], meter: ['number'],
9
+ range: ['number'], switch: ['boolean'], meter: ['number'], 'xy-series': ['xy-series'],
10
10
  'text-display': ['text', 'number', 'enum', 'boolean'], 'text-input': ['text'], select: ['enum']
11
11
  }
12
12
  function family (target) {
13
+ if (target.xySeries) return 'xy-series'
13
14
  if (target.controlSchemaVersion !== typeContract.schemaVersion) return null
14
15
  const definition = typeContract.types[String(target.controlType || '').toLowerCase()]
15
16
  if (!definition) return null
@@ -24,9 +25,11 @@ function createTargetMatcher (typeContract) {
24
25
  }
25
26
  function compatible (adapter, target, direction, candidate = {}) {
26
27
  if (adapter === 'meter' && !target.meter) return false
28
+ if (adapter === 'xy-series' && !target.xySeries) return false
27
29
  if (target.meter && (adapter !== 'meter' || direction !== 'read' || target.access !== 'ro')) return false
30
+ if (target.xySeries && (adapter !== 'xy-series' || direction !== 'read' || target.access !== 'ro')) return false
28
31
  if (target.kind !== 'param' || !families[adapter]?.includes(family(target))) return false
29
- if (direction === 'write' && ['meter', 'text-display'].includes(adapter)) return false
32
+ if (direction === 'write' && ['meter', 'xy-series', 'text-display'].includes(adapter)) return false
30
33
  if (!['read', 'write'].includes(direction)) return false
31
34
  if (!(direction === 'write' ? ['wo', 'rw'] : ['ro', 'rw']).includes(target.access)) return false
32
35
  if (candidate.unit && candidate.unit !== target.unit) return false
@@ -42,6 +45,7 @@ function controlTypeForAdapter (adapter, target) {
42
45
  throw new Error('Target is incompatible with this control')
43
46
  }
44
47
  if (adapter === 'meter' && target.meter) return 'meter'
48
+ if (adapter === 'xy-series' && target.xySeries) return 'xy-series'
45
49
  const family = targetFamily(target)
46
50
  if (family === 'number') return 'level'
47
51
  if (family === 'boolean') return 'mute'
@@ -60,7 +64,8 @@ function targetMeta (target, onebased) {
60
64
  min: target.min, max: target.max, enums: target.enums,
61
65
  ...(target.booleanEncoding ? { booleanEncoding: target.booleanEncoding } : {}),
62
66
  controlType: target.controlType, controlSchemaVersion: target.controlSchemaVersion,
63
- ...(target.meter ? { meter: meterRoute(target.meter), unit: target.unit || '' } : {})
67
+ ...(target.meter ? { meter: meterRoute(target.meter), unit: target.unit || '' } : {}),
68
+ ...(target.xySeries ? { xySeries: require('./xy-series-route').xySeriesRoute(target.xySeries) } : {})
64
69
  }
65
70
  }
66
71
 
package/gateway.cjs CHANGED
@@ -10,6 +10,8 @@ function createGateway ({
10
10
  onBoundCommand,
11
11
  onBoundLogin,
12
12
  onAuthenticatedRequest,
13
+ onSessionClosed,
14
+ holdEpoch,
13
15
  secureCookie = false,
14
16
  prefix,
15
17
  sessionTtlMs = 8 * 3600000,
@@ -62,6 +64,7 @@ function createGateway ({
62
64
  const session = sessions.get(subscriber.token)
63
65
  if (!session || session.expiry < now) {
64
66
  sessions.delete(subscriber.token)
67
+ onSessionClosed?.(subscriber.token)
65
68
  closeSubscriber(subscriber)
66
69
  continue
67
70
  }
@@ -128,6 +131,7 @@ function createGateway ({
128
131
  for (const [id, session] of sessions) {
129
132
  if (session.expiry < now) {
130
133
  sessions.delete(id)
134
+ onSessionClosed?.(id)
131
135
  closeSubscribersForToken(id)
132
136
  }
133
137
  }
@@ -172,7 +176,7 @@ function createGateway ({
172
176
  const token = crypto.randomBytes(32).toString('hex')
173
177
  const csrf = crypto.randomBytes(24).toString('hex')
174
178
  sessions.set(token, { csrf, expiry: now + sessionTtlMs })
175
- reply(res, 200, { room, username, csrf }, {
179
+ reply(res, 200, { room, username, csrf, ...(holdEpoch ? { holdEpoch } : {}) }, {
176
180
  'Set-Cookie': cookie(token, Math.ceil(sessionTtlMs / 1000), req)
177
181
  })
178
182
  if (typeof onBoundLogin === 'function') {
@@ -197,7 +201,7 @@ function createGateway ({
197
201
  if (onAuthenticatedRequest && await onAuthenticatedRequest(req, res,
198
202
  { token, expiry: session.expiry, room, user: username })) return true
199
203
  if (path === routePrefix + '/session' && req.method === 'GET') {
200
- reply(res, 200, { room, username, csrf: session.csrf })
204
+ reply(res, 200, { room, username, csrf: session.csrf, ...(holdEpoch ? { holdEpoch } : {}) })
201
205
  return true
202
206
  }
203
207
  if (path === routePrefix + '/events' && req.method === 'GET') {
@@ -226,6 +230,7 @@ function createGateway ({
226
230
  }
227
231
  if (path === routePrefix + '/logout' && req.method === 'POST') {
228
232
  sessions.delete(token)
233
+ onSessionClosed?.(token)
229
234
  closeSubscribersForToken(token)
230
235
  reply(res, 200, { ok: true }, { 'Set-Cookie': cookie('', 0, req) })
231
236
  return true
@@ -240,11 +245,12 @@ function createGateway ({
240
245
  reply(res, 403, { error: 'Region denied' })
241
246
  return true
242
247
  }
243
- if (await onBoundCommand(data, { room, user: username }) === false) {
248
+ const result = await onBoundCommand(data, { room, user: username, token, expiry: session.expiry })
249
+ if (result === false) {
244
250
  reply(res, 409, { error: 'Managed bridge is out of sync' })
245
251
  return true
246
252
  }
247
- reply(res, 200, { ok: true })
253
+ reply(res, 200, { ok: true, ...(typeof result?.held === 'boolean' ? { held: result.held } : {}) })
248
254
  return true
249
255
  }
250
256
 
@@ -25,7 +25,8 @@ function panelTargets (panel) {
25
25
  device: binding.device, path, kind: meta.kind, type: meta.type,
26
26
  access: meta.access, onebased: meta.onebased,
27
27
  ...(meta.booleanEncoding ? { booleanEncoding: meta.booleanEncoding } : {}),
28
- ...(meta.meter ? { meter: meta.meter } : {})
28
+ ...(meta.meter ? { meter: meta.meter } : {}),
29
+ ...(meta.xySeries ? { xySeries: meta.xySeries } : {})
29
30
  }
30
31
  const key = JSON.stringify([target.device, target.path])
31
32
  const previous = targets.get(key)
@@ -1,5 +1,6 @@
1
1
  const { configuredRoutes } = require('./control-bound-protocol')
2
2
  const { meterRoute } = require('./meter-route')
3
+ const { xySeriesRoute } = require('./xy-series-route')
3
4
 
4
5
  const supportedTypes = {
5
6
  boolean: { eventType: 'Boolean', eventOptions: { boolParamFilter: 'Any' } },
@@ -30,6 +31,7 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
30
31
  const readTargets = new Map()
31
32
  const writeTargets = new Map()
32
33
  const meterStreams = new Map()
34
+ const xySeriesStreams = new Map()
33
35
  const commandRules = []
34
36
  const commandWires = []
35
37
 
@@ -37,7 +39,7 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
37
39
  const target = targetIndex.get(JSON.stringify([device, path]))
38
40
  if (!target) throw new Error(`Missing target descriptor: ${device} ${path}`)
39
41
  if (target.kind !== 'param') throw new Error(`Only parameter targets are supported: ${path}`)
40
- if (!Object.hasOwn(supportedTypes, target.type)) {
42
+ if (!target.xySeries && !Object.hasOwn(supportedTypes, target.type)) {
41
43
  throw new Error(`Unsupported native parameter type: ${path} (${target.type})`)
42
44
  }
43
45
  if (['mute', 'power'].includes(type) && target.type !== 'boolean' &&
@@ -56,7 +58,10 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
56
58
  if (type === 'meter' && (!target.meter || !['integer', 'float'].includes(target.type) || target.access !== 'ro')) {
57
59
  throw new Error(`Meter control must bind to a readonly Meter target: ${path}`)
58
60
  }
59
- if (!['mute', 'power', 'level', 'dimmer', 'position', 'meter', 'text', 'source', 'preset'].includes(type)) {
61
+ if (type === 'xy-series' && (!target.xySeries || target.access !== 'ro')) {
62
+ throw new Error(`XY-series control must bind to a readonly XY-series target: ${path}`)
63
+ }
64
+ if (!['mute', 'power', 'level', 'dimmer', 'position', 'meter', 'xy-series', 'text', 'source', 'preset'].includes(type)) {
60
65
  throw new Error(`Control type requires a separate command adapter: ${type}`)
61
66
  }
62
67
  if (!['ro', 'wo', 'rw'].includes(target.access) || !target.access.includes(access)) {
@@ -134,6 +139,49 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
134
139
  }
135
140
  continue
136
141
  }
142
+ if (target.xySeries) {
143
+ const series = xySeriesRoute(target.xySeries)
144
+ if (control.mode !== 'read' || control.adapter !== 'xy-series') {
145
+ throw Error(`XY-series is read-only: ${id}`)
146
+ }
147
+ const key = JSON.stringify([target.device, series.streamId])
148
+ const previous = xySeriesStreams.get(key)
149
+ if (previous) {
150
+ if (JSON.stringify(previous.series) !== JSON.stringify(series) || previous.onebased !== target.onebased) {
151
+ throw Error(`Conflicting xy-series stream: ${series.streamId}`)
152
+ }
153
+ continue
154
+ }
155
+ const index = xySeriesStreams.size + 1
156
+ const statusId = `${scopeId}-xy-series-read-${index}`
157
+ const tagIds = []
158
+ xySeriesStreams.set(key, { series, onebased: target.onebased, statusId, tagIds })
159
+ for (const [role, path] of [['manifest', series.manifestPath], ['frame', series.eventPath]]) {
160
+ const eventId = `${scopeId}-xy-series-${role}-${index}`
161
+ const tagId = `${eventId}-tag`
162
+ tagIds.push(tagId)
163
+ const y = 540 + (index - 1) * 280 + (role === 'frame' ? 140 : 0)
164
+ if (role === 'manifest') nodes.push({
165
+ id: statusId, type: 'Status', z: subflowId, name: `Read xy-series manifest ${target.device}`,
166
+ x: 440, y: y - 60, mojo: { version: '1.0', device: [target.device],
167
+ parameter: path, onebased: target.onebased }, wires: [[tagId]]
168
+ })
169
+ nodes.push({ id: eventId, type: 'Event', z: subflowId,
170
+ name: `Watch xy-series ${role} ${target.device} ${series.streamId}`, x: 440, y,
171
+ mojo: { version: '1.0', device: [target.device], event: path,
172
+ eventKind: role === 'frame' ? 'Event' : 'Parameter', paramType: 'String',
173
+ onebased: target.onebased }, wires: [[tagId]] })
174
+ nodes.push({ id: tagId, type: 'change', z: subflowId,
175
+ name: `Identify xy-series ${role} ${target.device}`, x: 1000, y,
176
+ rules: [
177
+ { t: 'set', p: 'kind', pt: 'msg', to: 'device-xy-series-' + role, tot: 'str' },
178
+ { t: 'set', p: 'device', pt: 'msg', to: target.device, tot: 'str' },
179
+ { t: 'set', p: 'path', pt: 'msg', to: path, tot: 'str' },
180
+ { t: 'set', p: 'streamId', pt: 'msg', to: series.streamId, tot: 'str' }
181
+ ], wires: [[]] })
182
+ }
183
+ continue
184
+ }
137
185
  const key = JSON.stringify([target.device, target.path])
138
186
  if (readTargets.has(key)) continue
139
187
  const index = readTargets.size + 1
@@ -175,7 +223,11 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
175
223
 
176
224
  const commandSwitchId = `${scopeId}-commands`
177
225
  // 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
226
+ for (const node of nodes) {
227
+ if (node.id.startsWith(`${scopeId}-meter-`) || node.id.startsWith(`${scopeId}-xy-series-`)) {
228
+ node.y += readTargets.size * 170
229
+ }
230
+ }
179
231
  const routerId = `${scopeId}-router`
180
232
  if (commandRules.length) {
181
233
  nodes.push({
@@ -196,17 +248,18 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
196
248
  ],
197
249
  checkall: 'false', repair: false, outputs: 2,
198
250
  wires: [commandRules.length ? [commandSwitchId] : [],
199
- [...readTargets.values(), ...meterStreams.values()].map(target => target.statusId)]
251
+ [...readTargets.values(), ...meterStreams.values(), ...xySeriesStreams.values()].map(target => target.statusId)]
200
252
  })
201
253
  const subflow = {
202
254
  id: subflowId, type: 'subflow', name: `Binding bridge ${scopeId}`,
203
255
  info: 'Generated panel binding bridge. Requires a Controller node and authenticated command source.',
204
256
  category: 'panel binding',
205
257
  in: [{ x: 170, y: 150, wires: [{ id: routerId }] }],
206
- out: readTargets.size || meterStreams.size
258
+ out: readTargets.size || meterStreams.size || xySeriesStreams.size
207
259
  ? [{ x: meterStreams.size ? 1600 : 1290, y: Math.round((390 + (readTargets.size + meterStreams.size + 1) * 85) / 10) * 10,
208
260
  wires: [...readTargets.values()].map(target => ({ id: target.tagId, port: 0 }))
209
- .concat([...meterStreams.values()].flatMap(target => target.tagIds.map(id => ({ id, port: 0 })))) }]
261
+ .concat([...meterStreams.values()].flatMap(target => target.tagIds.map(id => ({ id, port: 0 }))))
262
+ .concat([...xySeriesStreams.values()].flatMap(target => target.tagIds.map(id => ({ id, port: 0 })))) }]
210
263
  : [],
211
264
  env: [], meta: {}, color: '#96bca7'
212
265
  }
@@ -215,8 +268,9 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
215
268
  manifest: {
216
269
  subflowId,
217
270
  controls: routes.size,
218
- nativeReads: readTargets.size + meterStreams.size,
271
+ nativeReads: readTargets.size + meterStreams.size + xySeriesStreams.size,
219
272
  nativeMeterStreams: meterStreams.size,
273
+ nativeXYSeriesStreams: xySeriesStreams.size,
220
274
  nativeWrites: writeTargets.size,
221
275
  requiresController: true,
222
276
  initialReadMessage: { kind: 'read-all' }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "node-red-contrib-room-tools",
3
- "version": "1.9.0",
3
+ "version": "1.9.3",
4
4
  "lockfileVersion": 2,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "node-red-contrib-room-tools",
9
- "version": "1.9.0",
9
+ "version": "1.9.3",
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.9.0",
3
+ "version": "1.9.3",
4
4
  "description": "Authenticated visual control panel binding for Node-RED and MUSE Automator",
5
5
  "license": "UNLICENSED",
6
6
  "engines": {
@@ -27,6 +27,10 @@
27
27
  "meter-frame.js",
28
28
  "panel-meter.js",
29
29
  "panel-meter-client.js",
30
+ "xy-series-route.js",
31
+ "xy-series-frame.js",
32
+ "panel-xy-series.js",
33
+ "panel-xy-series-client.js",
30
34
  "panel-template-instrumentation.js",
31
35
  "panel-visual-bindings.js",
32
36
  "panel-visual-apply.js",
@@ -34,6 +38,9 @@
34
38
  "panel-http.js",
35
39
  "panel-flow-editor-sync.js",
36
40
  "panel-binding-editor.js",
41
+ "panel-momentary.js",
42
+ "panel-momentary-client.js",
43
+ "panel-button-press.js",
37
44
  "native-binding-plan.js",
38
45
  "managed-native-bridge.js",
39
46
  "managed-bridge-revision.js",
@@ -7,9 +7,15 @@ 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()
16
+ const buttonPresses = new Map()
17
+ const momentaryButtons = new Map()
18
+ const momentaryStates = new Map()
13
19
  const feedbackModels = new Map()
14
20
  const pendingText = new Map()
15
21
  const cleanup = []
@@ -189,11 +195,13 @@ function initializeVisualBinding () {
189
195
  }
190
196
  function showFeedback () {
191
197
  for (const [id, binding] of saved) {
192
- if (binding.mode === 'write' || binding.readMeta?.meter) continue
198
+ if (binding.mode === 'write' || binding.readMeta?.meter || binding.readMeta?.xySeries) continue
193
199
  const node = nodeForBinding(binding)
194
200
  if (!node) continue
195
201
  if (!Object.prototype.hasOwnProperty.call(component.boundState, id)) continue
196
- const value = component.boundDisplayValue(id, component.boundState[id])
202
+ const value = node.tagName === 'BUTTON' && binding.adapter === 'switch'
203
+ ? component.controlValue(id, component.boundState[id])
204
+ : component.boundDisplayValue(id, component.boundState[id])
197
205
  const displayValue = ['range', 'meter'].includes(binding.adapter)
198
206
  ? mappedValue(Number(value), binding.readMeta || binding.writeMeta,
199
207
  numericRange(binding, node)) : value
@@ -230,6 +238,22 @@ function initializeVisualBinding () {
230
238
  }
231
239
  }
232
240
  function attachControls () {
241
+ for (const [node, button] of buttonPresses) {
242
+ const binding = saved.get(button.id)
243
+ if (!binding || nodeForBinding(binding) !== node) {
244
+ button.controller.dispose()
245
+ buttonPresses.delete(node)
246
+ attached.delete(node)
247
+ } else button.controller.check()
248
+ }
249
+ for (const [node, button] of momentaryButtons) {
250
+ const binding = saved.get(button.id)
251
+ if (!binding || nodeForBinding(binding) !== node) {
252
+ button.controller.dispose()
253
+ momentaryButtons.delete(node)
254
+ attached.delete(node)
255
+ } else button.controller.check()
256
+ }
233
257
  for (const node of root.querySelectorAll('[data-cp-auto]')) {
234
258
  if (attached.has(node)) continue
235
259
  const locator = node.getAttribute('data-cp-auto')
@@ -239,6 +263,22 @@ function initializeVisualBinding () {
239
263
  if (nodeForBinding(binding) !== node) continue
240
264
  attached.add(node)
241
265
  if (binding.mode === 'read') continue
266
+ if (node.tagName === 'BUTTON' && binding.adapter === 'switch') {
267
+ buttonPresses.set(node, { id, controller: component.__attachButtonPress({ node,
268
+ canPress: () => !disposed && !editing && component.boundAuthenticated && component.boundFeedbackConnected }) })
269
+ }
270
+ if (binding.buttonBehavior === 'momentary' && node.tagName === 'BUTTON') {
271
+ if (!momentaryStates.has(id)) momentaryStates.set(id, { seq: 0 })
272
+ const controller = component.__attachMomentaryButton({ node, id,
273
+ state: momentaryStates.get(id),
274
+ endpoint: component.__boundPanelConfig().endpoint,
275
+ getSession: () => component.__boundPanelSession,
276
+ canStart: () => !disposed && !editing && component.boundAuthenticated && component.boundFeedbackConnected,
277
+ request: (...args) => component.__panelFetch(...args),
278
+ onError: error => console.error('ControlPanel momentary command failed', error) })
279
+ momentaryButtons.set(node, { id, controller })
280
+ continue
281
+ }
242
282
  if (binding.component) {
243
283
  if (binding.adapter === 'text-input') {
244
284
  const commit = () => {
@@ -288,6 +328,15 @@ function initializeVisualBinding () {
288
328
  if (lastMeterFrame) applyMeterFrame(lastMeterFrame.frame, lastMeterFrame.ids)
289
329
  else invalidateMeters()
290
330
  }
331
+ if (xySeriesEntries.length) {
332
+ xySeriesTargets.clear()
333
+ for (const [id, binding] of xySeriesEntries) {
334
+ const node = nodeForBinding(binding)
335
+ if (node) xySeriesTargets.set(id, { node, binding })
336
+ }
337
+ if (lastXYSeriesFrame) applyXYSeriesFrame(lastXYSeriesFrame.frame, lastXYSeriesFrame.ids)
338
+ else invalidateXYSeries()
339
+ }
291
340
  showFeedback()
292
341
  }
293
342
  function applyMeterFrame (frame, ids) {
@@ -313,6 +362,32 @@ function initializeVisualBinding () {
313
362
  node.setAttribute('aria-invalid', 'true')
314
363
  }
315
364
  }
365
+ function applyXYSeriesFrame (frame, ids) {
366
+ lastXYSeriesFrame = { frame, ids }
367
+ for (let index = 0; index < ids.length; index++) {
368
+ const target = xySeriesTargets.get(ids[index])
369
+ if (!target) continue
370
+ const { node, binding } = target
371
+ node.setAttribute('data-cpv-xy-series-valid', String(frame.valid[index]))
372
+ node.setAttribute('data-cpv-xy-series-seq', String(frame.seq))
373
+ const points = frame.valid[index] ? frame.values[index].map(point => point.slice()) : []
374
+ if (binding.model) {
375
+ if (binding.repeat) setRepeatModel(binding, points)
376
+ else if (binding.model in component && !component.$options?.computed?.[binding.model]) component[binding.model] = points
377
+ }
378
+ }
379
+ }
380
+ function invalidateXYSeries () {
381
+ lastXYSeriesFrame = null
382
+ for (const { node, binding } of xySeriesTargets.values()) {
383
+ node.setAttribute('data-cpv-xy-series-valid', 'false')
384
+ node.setAttribute('aria-invalid', 'true')
385
+ if (binding.model) {
386
+ if (binding.repeat) setRepeatModel(binding, [])
387
+ else if (binding.model in component && !component.$options?.computed?.[binding.model]) component[binding.model] = []
388
+ }
389
+ }
390
+ }
316
391
  function attachModelControls () {
317
392
  for (const [id, binding] of saved) {
318
393
  if (!binding.component || !binding.model || binding.mode === 'read') continue
@@ -555,6 +630,17 @@ function initializeVisualBinding () {
555
630
  const modeOptions = ['meter', 'text-display'].includes(candidate.adapter)
556
631
  ? ['read'] : ['both', 'write', 'read']
557
632
  const mode = field(form, 'Mode', choose('Mode', modeOptions.map(value => ({ value, text: value })), existing?.mode || modeOptions[0]))
633
+ const behavior = candidate.button ? field(form, 'Button behavior', choose('Button behavior', [
634
+ { value: 'toggle', text: 'Click to toggle / 点击切换' },
635
+ { value: 'momentary', text: 'Hold to activate / 按住生效' }
636
+ ], existing?.buttonBehavior || 'toggle')) : null
637
+ function updateButtonMode () {
638
+ if (!behavior) return
639
+ mode.disabled = behavior.value === 'momentary'
640
+ if (mode.disabled) mode.value = 'both'
641
+ mode.closest('label').dataset.disabled = String(mode.disabled)
642
+ }
643
+ updateButtonMode()
558
644
  const device = field(form, 'Device', choose('Device', devices.filter(item => item.online)
559
645
  .map(item => ({ value: item.id, text: item.id })), existing?.device))
560
646
  const write = field(form, 'Write point', choose('Write point', [], existing?.writePath))
@@ -588,8 +674,10 @@ function initializeVisualBinding () {
588
674
  }
589
675
  write.value = oldWrite || ''
590
676
  read.value = oldRead || ''
591
- write.closest('label').hidden = mode.value === 'read'
592
- read.closest('label').hidden = mode.value === 'write'
677
+ write.disabled = mode.value === 'read'
678
+ read.disabled = mode.value === 'write'
679
+ write.closest('label').dataset.disabled = String(write.disabled)
680
+ read.closest('label').dataset.disabled = String(read.disabled)
593
681
  const available = mode.value === 'read' ? readCount
594
682
  : mode.value === 'write' ? writeCount : Math.min(writeCount, readCount)
595
683
  message.textContent = targets.length && !available ? 'No compatible points for this control'
@@ -613,8 +701,12 @@ function initializeVisualBinding () {
613
701
  targets = []; updateTargets(); message.textContent = error.message
614
702
  }
615
703
  })
616
- write.addEventListener('change', () => { read.value = write.value })
704
+ write.addEventListener('change', () => {
705
+ if (mode.value === 'both') read.value = write.value
706
+ })
617
707
  mode.addEventListener('change', updateTargets)
708
+ behavior?.addEventListener('change', () => { updateButtonMode(); updateTargets() })
709
+ updateTargets()
618
710
  if (device.value) device.dispatchEvent(new Event('change'))
619
711
  const actions = element('div', null, 'cpv-actions')
620
712
  actions.append(button('Cancel', () => finishBindingEdit(returnToBindings)))
@@ -634,11 +726,12 @@ function initializeVisualBinding () {
634
726
  message.textContent = 'Control ID must be unique and valid'; return
635
727
  }
636
728
  if (!name.value.trim() || !device.value || (mode.value !== 'read' && !write.value) ||
637
- (mode.value !== 'write' && !read.value) || !numbering.value) {
729
+ (mode.value !== 'write' && !read.value) || !numbering.value || (behavior && !behavior.value)) {
638
730
  message.textContent = 'Complete the binding fields'; return
639
731
  }
640
732
  const entry = {
641
733
  id: id.value, locator: candidate.locator, adapter: candidate.adapter,
734
+ ...(behavior ? { buttonBehavior: behavior.value } : {}),
642
735
  ...(candidate.model ? { model: candidate.model } : {}),
643
736
  ...(candidate.repeat ? { repeat: candidate.repeat, instanceKey: String(candidate.instanceKey) } : {}),
644
737
  label: name.value.trim(), mode: mode.value, device: device.value,
@@ -719,8 +812,9 @@ function initializeVisualBinding () {
719
812
  const baseCandidate = snapshot.candidates.find(item => item.locator === entry.locator && item.supported)
720
813
  const candidate = baseCandidate?.repeat ? { ...baseCandidate, instanceKey: entry.instanceKey } : baseCandidate
721
814
  const edit = button('Edit', () => bindingDialog(candidate, { returnToBindings: true }))
722
- edit.disabled = !candidate || !nodeForBinding(entry)
723
- if (edit.disabled) edit.title = 'This control is no longer available on the page'
815
+ // Existing mappings belong to the template, not the currently rendered tab.
816
+ edit.disabled = !candidate
817
+ if (edit.disabled) edit.title = 'This control is no longer supported or present in the template'
724
818
  actions.append(edit)
725
819
  actions.append(button('Remove', () => {
726
820
  draft = draft.filter(item => item.id !== entry.id)
@@ -762,6 +856,9 @@ function initializeVisualBinding () {
762
856
  if (candidate.adapter === 'meter' && (typeof value !== 'number' || !Number.isFinite(value))) {
763
857
  return { ...candidate, supported: false, reason: 'This Vuetify progress needs a numeric model field' }
764
858
  }
859
+ if (candidate.adapter === 'xy-series' && !Array.isArray(value)) {
860
+ return { ...candidate, supported: false, reason: 'This realtime array control needs an array model field' }
861
+ }
765
862
  if (candidate.adapter === 'select' && typeof value !== 'string' &&
766
863
  !(typeof value === 'number' && Number.isFinite(value))) {
767
864
  return { ...candidate, supported: false, reason: 'This Vuetify selection needs one scalar model field' }
@@ -822,11 +919,43 @@ function initializeVisualBinding () {
822
919
  }))
823
920
  } catch (error) { console.error('ControlPanel meter initialization failed', error) }
824
921
  }
922
+ if (this.__boundPanelConfig().xySeries && xySeriesEntries.length) {
923
+ try {
924
+ const ids = xySeriesEntries.map(([id]) => id).sort((a, b) => a.localeCompare(b))
925
+ xySeriesClient = this.__createPanelXYSeriesClient({ socket: this.$socket,
926
+ panelId: config.panelId, room: this.__boundPanelConfig().room, ids,
927
+ request: (...args) => component.__panelFetch(...args),
928
+ createReceiver: this.__createXYSeriesReceiver, createRenderer: this.__createXYSeriesRenderer,
929
+ apply: applyXYSeriesFrame, invalidate: invalidateXYSeries,
930
+ onUnauthorized: () => {
931
+ if (editing) { editSession = null; loginView() }
932
+ else component.clearBoundPanelSession()
933
+ } })
934
+ cleanup.push(() => xySeriesClient.close())
935
+ if (!editing) cleanup.push(this.$watch('boundAuthenticated', authenticated => {
936
+ xySeriesClient.setAuth(authenticated ? { endpoint: component.__boundPanelConfig().endpoint,
937
+ csrf: component.__boundPanelSession.csrf, header: 'X-Room-CSRF' } : null)
938
+ }))
939
+ } catch (error) { console.error('ControlPanel xy-series initialization failed', error) }
940
+ }
825
941
  observer = new MutationObserver(() => {
826
942
  attachControls()
827
943
  updateBindingMarkers()
828
944
  })
829
- observer.observe(root, { childList: true, subtree: true })
945
+ observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['disabled'] })
946
+ cleanup.push(() => {
947
+ for (const button of buttonPresses.values()) button.controller.dispose()
948
+ buttonPresses.clear()
949
+ for (const button of momentaryButtons.values()) button.controller.dispose()
950
+ momentaryButtons.clear()
951
+ momentaryStates.clear()
952
+ })
953
+ cleanup.push(this.$watch(() => component.boundAuthenticated && component.boundFeedbackConnected, connected => {
954
+ if (!connected) {
955
+ for (const button of buttonPresses.values()) button.controller.release()
956
+ for (const button of momentaryButtons.values()) button.controller.release()
957
+ }
958
+ }))
830
959
  const unwatch = this.$watch('boundState', showFeedback)
831
960
  cleanup.push(() => unwatch(), () => observer.disconnect(), () => toolbar?.remove(),
832
961
  () => closeOverlay(), () => closeLoginScreen())
@@ -834,6 +963,7 @@ function initializeVisualBinding () {
834
963
 
835
964
  const style = element('style')
836
965
  style.textContent = `
966
+ button[data-cpv-press="true"]{translate:0 2px !important;box-shadow:inset 0 2px 4px #0003 !important}
837
967
  .cpv-toolbar,.cpv-dialog,.cpv-menu{font:14px/1.4 system-ui,sans-serif;color:#182224;background:#fff}
838
968
  .cpv-toolbar{position:fixed;z-index:2147483000;top:12px;right:12px;display:flex;align-items:center;gap:8px;padding:8px;border:1px solid #9baaad;box-shadow:0 8px 24px #0003;max-width:calc(100vw - 24px)}
839
969
  .cpv-toolbar button,.cpv-dialog button{padding:7px 11px;border:1px solid #809296;background:#fff;color:#182224;border-radius:4px;cursor:pointer;font:inherit}
@@ -884,6 +1014,7 @@ function initializeVisualBinding () {
884
1014
  .cpv-dialog{width:min(460px,100%);max-height:min(90vh,720px);overflow:auto;padding:28px 18px 18px;border-radius:6px;box-shadow:0 12px 36px #0005}
885
1015
  .cpv-dialog h2{font-size:18px;margin:0 0 20px}.cpv-field{display:block;margin:10px 0}.cpv-field span{display:block;font-size:12px;margin-bottom:4px}
886
1016
  .cpv-field input,.cpv-field select{box-sizing:border-box;width:100%;padding:8px;border:1px solid #86999c;border-radius:4px;background:white;color:#182224;font:inherit}
1017
+ .cpv-field[data-disabled=true] span{color:#66787c}.cpv-field select:disabled{background:#edf1f2;color:#66787c;border-color:#c5cfd1;cursor:not-allowed;opacity:1}
887
1018
  .cpv-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}.cpv-dialog>button:last-child{margin-top:20px}.cpv-message{min-height:18px;font-size:12px}
888
1019
  .cpv-menu{position:fixed;z-index:2147483002;width:170px;padding:4px;border:1px solid #9baaad;box-shadow:0 8px 24px #0003}
889
1020
  .cpv-menu button{display:block;width:100%;padding:8px;text-align:left;border:0;background:white;color:#182224;font:inherit;cursor:pointer}
@@ -0,0 +1,46 @@
1
+ // Serialized into the Dashboard component; press appearance never changes device state.
2
+ function attachButtonPress ({ node, canPress }) {
3
+ let pointer = null
4
+ const keys = new Set(), listeners = []
5
+ function listen (target, name, callback) {
6
+ target.addEventListener(name, callback)
7
+ listeners.push(() => target.removeEventListener(name, callback))
8
+ }
9
+ function render () {
10
+ if (pointer !== null || keys.size) node.setAttribute('data-cpv-press', 'true')
11
+ else node.removeAttribute('data-cpv-press')
12
+ }
13
+ function release () { pointer = null; keys.clear(); render() }
14
+ function allowed () { return node.isConnected && !node.disabled && !document.hidden && canPress() }
15
+ listen(node, 'pointerdown', event => {
16
+ if (event.button !== 0 || pointer !== null || !allowed()) return
17
+ pointer = event.pointerId
18
+ render()
19
+ })
20
+ const pointerEnd = event => {
21
+ if (pointer !== event.pointerId) return
22
+ pointer = null
23
+ render()
24
+ }
25
+ listen(window, 'pointerup', pointerEnd)
26
+ listen(window, 'pointercancel', pointerEnd)
27
+ listen(node, 'lostpointercapture', pointerEnd)
28
+ listen(node, 'keydown', event => {
29
+ if (![' ', 'Enter'].includes(event.key) || !allowed()) return
30
+ keys.add(event.key)
31
+ render()
32
+ })
33
+ listen(window, 'keyup', event => { if (keys.delete(event.key)) render() })
34
+ listen(node, 'blur', release)
35
+ listen(window, 'blur', release)
36
+ listen(window, 'offline', release)
37
+ listen(window, 'pagehide', release)
38
+ listen(document, 'visibilitychange', () => { if (document.hidden) release() })
39
+ return {
40
+ release,
41
+ check () { if (!allowed()) release() },
42
+ dispose () { release(); for (const remove of listeners) remove() }
43
+ }
44
+ }
45
+
46
+ module.exports = { attachButtonPress }